mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
51 Commits
v2026.3.23
...
feat/cron-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3394b459 | ||
|
|
912b55ac18 | ||
|
|
9560d8cf9a | ||
|
|
d50e2677b6 | ||
|
|
a043668ba4 | ||
|
|
0d020469e4 | ||
|
|
5be223ec1c | ||
|
|
9426abbccb | ||
|
|
2963f49659 | ||
|
|
37b8606e48 | ||
|
|
2620f30500 | ||
|
|
f7b031f2dd | ||
|
|
57e7299e20 | ||
|
|
49ae78865a | ||
|
|
150d4a7cc7 | ||
|
|
1e1180d142 | ||
|
|
31cb9e44dd | ||
|
|
133dc7956f | ||
|
|
8bc56095ed | ||
|
|
640df8608a | ||
|
|
317063754b | ||
|
|
588dbe510f | ||
|
|
b71877bb58 | ||
|
|
8c17cff3d4 | ||
|
|
bef4f37446 | ||
|
|
9c6dc098ce | ||
|
|
28a3fc49b5 | ||
|
|
cb43285e52 | ||
|
|
a09e37bcbb | ||
|
|
9b03530a21 | ||
|
|
2621ce491f | ||
|
|
e23915c501 | ||
|
|
755d4f2d39 | ||
|
|
e7b5fb824d | ||
|
|
0c1731136d | ||
|
|
efc7c4f339 | ||
|
|
512a6e4f9a | ||
|
|
968c2a7010 | ||
|
|
334da9ec46 | ||
|
|
df8a40f7e2 | ||
|
|
43762330cd | ||
|
|
0e812c0e4f | ||
|
|
8f8a85567f | ||
|
|
356e9706f2 | ||
|
|
3bb94559d6 | ||
|
|
4c560d0f41 | ||
|
|
8c4dbc0f71 | ||
|
|
ea5cb56e61 | ||
|
|
40f343e5d3 | ||
|
|
1404eb575f | ||
|
|
dba4dc632b |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -240,6 +240,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: acpx":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -418,12 +418,23 @@ jobs:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -495,6 +506,12 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: matrix.task == 'test'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
@@ -512,7 +529,7 @@ jobs:
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -2,6 +2,31 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.26 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
|
||||
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
|
||||
- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
|
||||
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
|
||||
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
|
||||
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
|
||||
- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.
|
||||
- Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673.
|
||||
- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn.
|
||||
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
|
||||
- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant.
|
||||
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
|
||||
- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.
|
||||
- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. Thanks @gumadeiras.
|
||||
- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Cron/CLI: add `--session` for session-key routing and rename target selection to `--session-target` for `openclaw cron add/edit`, including `--clear-session` on edit for unsetting the key. (#27167) thanks @Matt-Hulme.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
@@ -48,29 +73,29 @@ Docs: https://docs.openclaw.ai
|
||||
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
|
||||
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
|
||||
- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.
|
||||
- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
|
||||
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.26`). Thanks @luz-oasis for reporting.
|
||||
- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.26`). Thanks @zdi-disclosures for reporting.
|
||||
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
|
||||
- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
|
||||
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
|
||||
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
|
||||
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
|
||||
- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
|
||||
- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
|
||||
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
@@ -23,7 +23,9 @@ COPY --chown=node:node patches ./patches
|
||||
COPY --chown=node:node scripts ./scripts
|
||||
|
||||
USER node
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602250
|
||||
versionName = "2026.2.25"
|
||||
versionCode = 202602260
|
||||
versionName = "2026.2.26"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -146,6 +146,7 @@ dependencies {
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -92,6 +92,10 @@ class NodeRuntime(context: Context) {
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
@@ -123,6 +127,7 @@ class NodeRuntime(context: Context) {
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
@@ -131,6 +136,8 @@ class NodeRuntime(context: Context) {
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
|
||||
@@ -2,13 +2,18 @@ package ai.openclaw.android.gateway
|
||||
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadToken(deviceId: String, role: String): String?
|
||||
fun saveToken(deviceId: String, role: String, token: String)
|
||||
}
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ data class GatewayConnectOptions(
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
@@ -200,9 +200,7 @@ class GatewaySession(
|
||||
suspend fun connect() {
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val httpScheme = if (tls != null) "https" else "http"
|
||||
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).header("Origin", origin).build()
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
@@ -535,16 +533,8 @@ class GatewaySession(
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
|
||||
return InvokeResult.error(code = parsed.code, message = parsed.message)
|
||||
}
|
||||
|
||||
private fun failPending() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
data class ParsedInvokeError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val hadExplicitCode: Boolean,
|
||||
) {
|
||||
val prefixedMessage: String
|
||||
get() = "$code: $message"
|
||||
}
|
||||
|
||||
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
|
||||
}
|
||||
|
||||
val parts = trimmed.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return ParsedInvokeError(
|
||||
code = code,
|
||||
message = rest.ifEmpty { trimmed },
|
||||
hadExplicitCode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
|
||||
}
|
||||
|
||||
fun parseInvokeErrorFromThrowable(
|
||||
err: Throwable,
|
||||
fallbackMessage: String = "error",
|
||||
): ParsedInvokeError {
|
||||
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
|
||||
return parseInvokeErrorMessage(raw)
|
||||
}
|
||||
@@ -81,8 +81,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 800
|
||||
val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 1600
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
|
||||
@@ -7,12 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
@@ -80,32 +74,12 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
add(OpenClawCanvasCommand.Hide.rawValue)
|
||||
add(OpenClawCanvasCommand.Navigate.rawValue)
|
||||
add(OpenClawCanvasCommand.Eval.rawValue)
|
||||
add(OpenClawCanvasCommand.Snapshot.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Push.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Reset.rawValue)
|
||||
add(OpenClawScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled()) {
|
||||
add(OpenClawCameraCommand.Snap.rawValue)
|
||||
add(OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (smsAvailable()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
add("debug.logs")
|
||||
add("debug.ed25519")
|
||||
}
|
||||
add("app.update")
|
||||
}
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
|
||||
}
|
||||
|
||||
data class DeviceNotificationEntry(
|
||||
val key: String,
|
||||
val packageName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
val subText: String?,
|
||||
val category: String?,
|
||||
val channelId: String?,
|
||||
val postTimeMs: Long,
|
||||
val isOngoing: Boolean,
|
||||
val isClearable: Boolean,
|
||||
)
|
||||
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
|
||||
|
||||
fun replace(entries: List<DeviceNotificationEntry>) {
|
||||
synchronized(lock) {
|
||||
byKey.clear()
|
||||
for (entry in entries) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsert(entry: DeviceNotificationEntry) {
|
||||
synchronized(lock) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
synchronized(lock) {
|
||||
byKey.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun setConnected(value: Boolean) {
|
||||
synchronized(lock) {
|
||||
connected = value
|
||||
if (!value) {
|
||||
byKey.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(enabled: Boolean): DeviceNotificationSnapshot {
|
||||
val (isConnected, entries) =
|
||||
synchronized(lock) {
|
||||
connected to byKey.values.sortedByDescending { it.postTimeMs }
|
||||
}
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = enabled,
|
||||
connected = isConnected,
|
||||
notifications = entries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
DeviceNotificationStore.setConnected(true)
|
||||
refreshActiveNotifications()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
DeviceNotificationStore.setConnected(false)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
val key = sbn?.key ?: return
|
||||
DeviceNotificationStore.remove(key)
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
val entries =
|
||||
runCatching {
|
||||
activeNotifications
|
||||
?.mapNotNull { it.toEntry() }
|
||||
?: emptyList()
|
||||
}.getOrElse { emptyList() }
|
||||
DeviceNotificationStore.replace(entries)
|
||||
}
|
||||
|
||||
private fun StatusBarNotification.toEntry(): DeviceNotificationEntry {
|
||||
val extras = notification.extras
|
||||
val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime"
|
||||
val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
|
||||
val body =
|
||||
sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT))
|
||||
?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT))
|
||||
val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
|
||||
return DeviceNotificationEntry(
|
||||
key = keyValue,
|
||||
packageName = packageName,
|
||||
title = title,
|
||||
text = body,
|
||||
subText = subText,
|
||||
category = notification.category?.trim()?.ifEmpty { null },
|
||||
channelId = notification.channelId?.trim()?.ifEmpty { null },
|
||||
postTimeMs = postTime,
|
||||
isOngoing = isOngoing,
|
||||
isClearable = isClearable,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
}
|
||||
|
||||
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
|
||||
return DeviceNotificationStore.snapshot(enabled = enabled)
|
||||
}
|
||||
|
||||
fun requestServiceRebind(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always,
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Present.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Hide.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Navigate.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Eval.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Snapshot.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Push.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.PushJSONL.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Reset.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Clip.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawLocationCommand.Get.rawValue,
|
||||
availability = InvokeCommandAvailability.LocationEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.ed25519",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(name = "app.update"),
|
||||
)
|
||||
|
||||
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
fun advertisedCommands(
|
||||
cameraEnabled: Boolean,
|
||||
locationEnabled: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
debugBuild: Boolean,
|
||||
): List<String> {
|
||||
return all
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> smsAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> debugBuild
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
@@ -12,6 +13,7 @@ class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
private val cameraHandler: CameraHandler,
|
||||
private val locationHandler: LocationHandler,
|
||||
private val notificationsHandler: NotificationsHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
@@ -20,40 +22,25 @@ class InvokeDispatcher(
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
// Check foreground requirement for canvas/camera/screen commands
|
||||
if (
|
||||
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
val spec =
|
||||
InvokeCommandRegistry.find(command)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check camera enabled
|
||||
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
|
||||
if (spec.requiresForeground && !isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
// Check location enabled
|
||||
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
availabilityError(spec.availability)?.let { return it }
|
||||
|
||||
return when (command) {
|
||||
// Canvas commands
|
||||
@@ -75,53 +62,33 @@ class InvokeDispatcher(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
withCanvasAvailable {
|
||||
val result = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
}
|
||||
OpenClawCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
withCanvasAvailable {
|
||||
val base64 =
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
}
|
||||
|
||||
// A2UI commands
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue ->
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
}
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
@@ -132,22 +99,14 @@ class InvokeDispatcher(
|
||||
message = err.message ?: "invalid A2UI payload"
|
||||
)
|
||||
}
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
}
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
@@ -157,6 +116,9 @@ class InvokeDispatcher(
|
||||
// Location command
|
||||
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
|
||||
@@ -170,11 +132,80 @@ class InvokeDispatcher(
|
||||
// App update
|
||||
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
|
||||
|
||||
else ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
private suspend fun withCanvasAvailable(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
block()
|
||||
} catch (_: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
|
||||
return when (availability) {
|
||||
InvokeCommandAvailability.Always -> null
|
||||
InvokeCommandAvailability.CameraEnabled ->
|
||||
if (cameraEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.LocationEnabled ->
|
||||
if (locationEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -37,14 +38,9 @@ fun parseHexColorArgb(raw: String?): Long? {
|
||||
}
|
||||
|
||||
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
return code to "$code: $message"
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
|
||||
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
|
||||
return parsed.code to message
|
||||
}
|
||||
|
||||
fun normalizeMainKey(raw: String?): String? {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
fun requestServiceRebind(context: Context)
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
|
||||
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
if (!enabled) {
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
)
|
||||
}
|
||||
return DeviceNotificationListenerService.snapshot(context, enabled = true)
|
||||
}
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val stateProvider: NotificationsStateProvider,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
return buildJsonObject {
|
||||
put("enabled", JsonPrimitive(snapshot.enabled))
|
||||
put("connected", JsonPrimitive(snapshot.connected))
|
||||
put("count", JsonPrimitive(snapshot.notifications.size))
|
||||
put(
|
||||
"notifications",
|
||||
JsonArray(
|
||||
snapshot.notifications.map { entry ->
|
||||
buildJsonObject {
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
stateProvider: NotificationsStateProvider,
|
||||
): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider)
|
||||
}
|
||||
}
|
||||
@@ -69,3 +69,12 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeErrorParserTest {
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code)
|
||||
assertEquals("grant Camera permission", parsed.message)
|
||||
assertTrue(parsed.hadExplicitCode)
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("IllegalStateException: boom")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("IllegalStateException: boom", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() {
|
||||
val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("fallback", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
debugBuild = false,
|
||||
)
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
debugBuild = true,
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NotificationsHandlerTest {
|
||||
@Test
|
||||
fun notificationsListReturnsStatusPayloadWhenDisabled() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertFalse(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(0, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListRequestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n1")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(1, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListDoesNotRequestRebindWhenConnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertTrue(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextReturnsNullForBlankInput() {
|
||||
assertNull(sanitizeNotificationText(null))
|
||||
assertNull(sanitizeNotificationText(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextTrimsAndTruncates() {
|
||||
val value = " ${"x".repeat(600)} "
|
||||
val sanitized = sanitizeNotificationText(value)
|
||||
|
||||
assertEquals(512, sanitized?.length)
|
||||
assertTrue((sanitized ?: "").all { it == 'x' })
|
||||
}
|
||||
|
||||
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
|
||||
val payloadJson = result.payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(payloadJson).jsonObject
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun sampleEntry(key: String): DeviceNotificationEntry =
|
||||
DeviceNotificationEntry(
|
||||
key = key,
|
||||
packageName = "com.example.app",
|
||||
title = "Title",
|
||||
text = "Text",
|
||||
subText = null,
|
||||
category = null,
|
||||
channelId = null,
|
||||
postTimeMs = 123L,
|
||||
isOngoing = false,
|
||||
isClearable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeNotificationsStateProvider(
|
||||
private val snapshot: DeviceNotificationSnapshot,
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
rebindRequests += 1
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,9 @@ class OpenClawProtocolConstantsTest {
|
||||
fun screenCommandsUseStableStrings() {
|
||||
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsCommandsUseStableStrings() {
|
||||
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.25</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260225</string>
|
||||
<string>20260226</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.25</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260225</string>
|
||||
<string>20260226</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -92,8 +92,8 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -146,8 +146,8 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||
@@ -176,8 +176,8 @@ targets:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
@@ -200,8 +200,8 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
@@ -234,5 +234,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.25</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602250</string>
|
||||
<string>202602260</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -20,6 +20,78 @@ require_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
read_config_gateway_token() {
|
||||
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$config_path" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
|
||||
gateway = cfg.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
raise SystemExit(0)
|
||||
auth = gateway.get("auth")
|
||||
if not isinstance(auth, dict):
|
||||
raise SystemExit(0)
|
||||
token = auth.get("token")
|
||||
if isinstance(token, str):
|
||||
token = token.strip()
|
||||
if token:
|
||||
print(token)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node - "$config_path" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const configPath = process.argv[2];
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const token = cfg?.gateway?.auth?.token;
|
||||
if (typeof token === "string" && token.trim().length > 0) {
|
||||
process.stdout.write(token.trim());
|
||||
}
|
||||
} catch {
|
||||
// Keep docker-setup resilient when config parsing fails.
|
||||
}
|
||||
NODE
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_control_ui_allowed_origins() {
|
||||
if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local allowed_origin_json
|
||||
local current_allowed_origins
|
||||
allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")"
|
||||
current_allowed_origins="$(
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config get gateway.controlUi.allowedOrigins 2>/dev/null || true
|
||||
)"
|
||||
current_allowed_origins="${current_allowed_origins//$'\r'/}"
|
||||
|
||||
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
|
||||
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null
|
||||
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
|
||||
}
|
||||
|
||||
contains_disallowed_chars() {
|
||||
local value="$1"
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
@@ -97,7 +169,11 @@ export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
|
||||
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
|
||||
|
||||
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
|
||||
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
|
||||
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
||||
else
|
||||
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
|
||||
@@ -273,6 +349,10 @@ echo " - Install Gateway daemon: No"
|
||||
echo ""
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon
|
||||
|
||||
echo ""
|
||||
echo "==> Control UI origin allowlist"
|
||||
ensure_control_ui_allowed_origins
|
||||
|
||||
echo ""
|
||||
echo "==> Provider setup (optional)"
|
||||
echo "WhatsApp (QR):"
|
||||
|
||||
@@ -38,7 +38,7 @@ Create a one-shot reminder, verify it exists, and run it immediately:
|
||||
openclaw cron add \
|
||||
--name "Reminder" \
|
||||
--at "2026-02-01T16:00:00Z" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Reminder: check the cron docs draft" \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
@@ -55,7 +55,7 @@ openclaw cron add \
|
||||
--name "Morning brief" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Summarize overnight updates." \
|
||||
--announce \
|
||||
--channel slack \
|
||||
@@ -479,7 +479,7 @@ One-shot reminder (UTC ISO, auto-delete after success):
|
||||
openclaw cron add \
|
||||
--name "Send reminder" \
|
||||
--at "2026-01-12T18:00:00Z" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Reminder: submit expense report." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
@@ -491,7 +491,7 @@ One-shot reminder (main session, wake immediately):
|
||||
openclaw cron add \
|
||||
--name "Calendar check" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Next heartbeat: check calendar." \
|
||||
--wake now
|
||||
```
|
||||
@@ -503,7 +503,7 @@ openclaw cron add \
|
||||
--name "Morning status" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Summarize inbox + calendar for today." \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
@@ -518,7 +518,7 @@ openclaw cron add \
|
||||
--cron "0 * * * * *" \
|
||||
--tz "UTC" \
|
||||
--stagger 30s \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Run minute watcher checks." \
|
||||
--announce
|
||||
```
|
||||
@@ -530,7 +530,7 @@ openclaw cron add \
|
||||
--name "Nightly summary (topic)" \
|
||||
--cron "0 22 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Summarize today; send to the nightly topic." \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
@@ -544,7 +544,7 @@ openclaw cron add \
|
||||
--name "Deep analysis" \
|
||||
--cron "0 6 * * 1" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Weekly deep analysis of project progress." \
|
||||
--model "opus" \
|
||||
--thinking high \
|
||||
@@ -557,7 +557,7 @@ Agent selection (multi-agent setups):
|
||||
|
||||
```bash
|
||||
# Pin a job to agent "ops" (falls back to default if that agent is missing)
|
||||
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
|
||||
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session-target isolated --message "Check ops queue" --agent ops
|
||||
|
||||
# Switch or clear the agent on an existing job
|
||||
openclaw cron edit <jobId> --agent ops
|
||||
|
||||
@@ -106,7 +106,7 @@ openclaw cron add \
|
||||
--name "Morning briefing" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/New_York" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||
--model opus \
|
||||
--announce \
|
||||
@@ -122,7 +122,7 @@ This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces
|
||||
openclaw cron add \
|
||||
--name "Meeting reminder" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Reminder: standup meeting starts in 10 minutes." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
@@ -178,13 +178,13 @@ The most efficient setup uses **both**:
|
||||
|
||||
```bash
|
||||
# Daily morning briefing at 7am
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session-target isolated --message "..." --announce
|
||||
|
||||
# Weekly project review on Mondays at 9am
|
||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session-target isolated --message "..." --model opus
|
||||
|
||||
# One-shot reminder
|
||||
openclaw cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
|
||||
openclaw cron add --name "Call back" --at "2h" --session-target main --system-event "Call back the client" --wake now
|
||||
```
|
||||
|
||||
## Lobster: Deterministic workflows with approvals
|
||||
@@ -229,7 +229,7 @@ Both heartbeat and cron can interact with the main session, but differently:
|
||||
|
||||
### When to use main session cron
|
||||
|
||||
Use `--session main` with `--system-event` when you want:
|
||||
Use `--session-target main` with `--system-event` when you want:
|
||||
|
||||
- The reminder/event to appear in main session context
|
||||
- The agent to handle it during the next heartbeat with full context
|
||||
@@ -239,14 +239,14 @@ Use `--session main` with `--system-event` when you want:
|
||||
openclaw cron add \
|
||||
--name "Check project" \
|
||||
--every "4h" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Time for a project health check" \
|
||||
--wake now
|
||||
```
|
||||
|
||||
### When to use isolated cron
|
||||
|
||||
Use `--session isolated` when you want:
|
||||
Use `--session-target isolated` when you want:
|
||||
|
||||
- A clean slate without prior context
|
||||
- Different model or thinking settings
|
||||
@@ -257,7 +257,7 @@ Use `--session isolated` when you want:
|
||||
openclaw cron add \
|
||||
--name "Deep analysis" \
|
||||
--cron "0 6 * * 0" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Weekly codebase analysis..." \
|
||||
--model opus \
|
||||
--thinking high \
|
||||
|
||||
@@ -376,6 +376,12 @@ Example:
|
||||
|
||||
If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode).
|
||||
|
||||
Multi-account precedence:
|
||||
|
||||
- `channels.discord.accounts.default.allowFrom` applies only to the `default` account.
|
||||
- Named accounts inherit `channels.discord.allowFrom` when their own `allowFrom` is unset.
|
||||
- Named accounts do not inherit `channels.discord.accounts.default.allowFrom`.
|
||||
|
||||
DM target format for delivery:
|
||||
|
||||
- `user:<id>`
|
||||
|
||||
@@ -43,7 +43,14 @@ Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `sl
|
||||
Stored under `~/.openclaw/credentials/`:
|
||||
|
||||
- Pending requests: `<channel>-pairing.json`
|
||||
- Approved allowlist store: `<channel>-allowFrom.json`
|
||||
- Approved allowlist store:
|
||||
- Default account: `<channel>-allowFrom.json`
|
||||
- Non-default account: `<channel>-<accountId>-allowFrom.json`
|
||||
|
||||
Account scoping behavior:
|
||||
|
||||
- Non-default accounts read/write only their scoped allowlist file.
|
||||
- Default account uses the channel-scoped unscoped allowlist file.
|
||||
|
||||
Treat these as sensitive (they gate access to your assistant).
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||
- `dm.groupEnabled` (group DMs default false)
|
||||
- `dm.groupChannels` (optional MPIM allowlist)
|
||||
|
||||
Multi-account precedence:
|
||||
|
||||
- `channels.slack.accounts.default.allowFrom` applies only to the `default` account.
|
||||
- Named accounts inherit `channels.slack.allowFrom` when their own `allowFrom` is unset.
|
||||
- Named accounts do not inherit `channels.slack.accounts.default.allowFrom`.
|
||||
|
||||
Pairing in DMs uses `openclaw pairing approve slack <code>`.
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -719,6 +719,10 @@ Primary reference:
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- Multi-account precedence:
|
||||
- `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account.
|
||||
- Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset.
|
||||
- Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`.
|
||||
- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
|
||||
- `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.requireMention`: mention gating default.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw agents` (list/add/delete/set identity)"
|
||||
summary: "CLI reference for `openclaw agents` (list/add/delete/bindings/bind/unbind/set identity)"
|
||||
read_when:
|
||||
- You want multiple isolated agents (workspaces + routing + auth)
|
||||
title: "agents"
|
||||
@@ -19,11 +19,59 @@ Related:
|
||||
```bash
|
||||
openclaw agents list
|
||||
openclaw agents add work --workspace ~/.openclaw/workspace-work
|
||||
openclaw agents bindings
|
||||
openclaw agents bind --agent work --bind telegram:ops
|
||||
openclaw agents unbind --agent work --bind telegram:ops
|
||||
openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity
|
||||
openclaw agents set-identity --agent main --avatar avatars/openclaw.png
|
||||
openclaw agents delete work
|
||||
```
|
||||
|
||||
## Routing bindings
|
||||
|
||||
Use routing bindings to pin inbound channel traffic to a specific agent.
|
||||
|
||||
List bindings:
|
||||
|
||||
```bash
|
||||
openclaw agents bindings
|
||||
openclaw agents bindings --agent work
|
||||
openclaw agents bindings --json
|
||||
```
|
||||
|
||||
Add bindings:
|
||||
|
||||
```bash
|
||||
openclaw agents bind --agent work --bind telegram:ops --bind discord:guild-a
|
||||
```
|
||||
|
||||
If you omit `accountId` (`--bind <channel>`), OpenClaw resolves it from channel defaults and plugin setup hooks when available.
|
||||
|
||||
### Binding scope behavior
|
||||
|
||||
- A binding without `accountId` matches the channel default account only.
|
||||
- `accountId: "*"` is the channel-wide fallback (all accounts) and is less specific than an explicit account binding.
|
||||
- If the same agent already has a matching channel binding without `accountId`, and you later bind with an explicit or resolved `accountId`, OpenClaw upgrades that existing binding in place instead of adding a duplicate.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# initial channel-only binding
|
||||
openclaw agents bind --agent work --bind telegram
|
||||
|
||||
# later upgrade to account-scoped binding
|
||||
openclaw agents bind --agent work --bind telegram:ops
|
||||
```
|
||||
|
||||
After the upgrade, routing for that binding is scoped to `telegram:ops`. If you also want default-account routing, add it explicitly (for example `--bind telegram:default`).
|
||||
|
||||
Remove bindings:
|
||||
|
||||
```bash
|
||||
openclaw agents unbind --agent work --bind telegram:ops
|
||||
openclaw agents unbind --agent work --all
|
||||
```
|
||||
|
||||
## Identity files
|
||||
|
||||
Each agent workspace can include an `IDENTITY.md` at the workspace root:
|
||||
|
||||
@@ -35,6 +35,26 @@ openclaw channels remove --channel telegram --delete
|
||||
|
||||
Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc).
|
||||
|
||||
When you run `openclaw channels add` without flags, the interactive wizard can prompt:
|
||||
|
||||
- account ids per selected channel
|
||||
- optional display names for those accounts
|
||||
- `Bind configured channel accounts to agents now?`
|
||||
|
||||
If you confirm bind now, the wizard asks which agent should own each configured channel account and writes account-scoped routing bindings.
|
||||
|
||||
You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)).
|
||||
|
||||
When you add a non-default account to a channel that is still using single-account top-level settings (no `channels.<channel>.accounts` entries yet), OpenClaw moves account-scoped single-account top-level values into `channels.<channel>.accounts.default`, then writes the new account. This preserves the original account behavior while moving to the multi-account shape.
|
||||
|
||||
Routing behavior stays consistent:
|
||||
|
||||
- Existing channel-only bindings (no `accountId`) continue to match the default account.
|
||||
- `channels add` does not auto-create or rewrite bindings in non-interactive mode.
|
||||
- Interactive setup can optionally add account-scoped bindings.
|
||||
|
||||
If your config was already in a mixed state (named accounts present, missing `default`, and top-level single-account values still set), run `openclaw doctor --fix` to move account-scoped values into `accounts.default`.
|
||||
|
||||
## Login / logout (interactive)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -400,6 +400,8 @@ Subcommands:
|
||||
- Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `openclaw doctor`).
|
||||
- `channels logs`: show recent channel logs from the gateway log file.
|
||||
- `channels add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
|
||||
- When adding a non-default account to a channel still using single-account top-level config, OpenClaw moves account-scoped values into `channels.<channel>.accounts.default` before writing the new account.
|
||||
- Non-interactive `channels add` does not auto-create/upgrade bindings; channel-only bindings continue to match the default account.
|
||||
- `channels remove`: disable by default; pass `--delete` to remove config entries without prompts.
|
||||
- `channels login`: interactive channel login (WhatsApp Web only).
|
||||
- `channels logout`: log out of a channel session (if supported).
|
||||
@@ -574,7 +576,37 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--json`
|
||||
|
||||
Binding specs use `channel[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used.
|
||||
Binding specs use `channel[:accountId]`. When `accountId` is omitted, OpenClaw may resolve account scope via channel defaults/plugin hooks; otherwise it is a channel binding without explicit account scope.
|
||||
|
||||
#### `agents bindings`
|
||||
|
||||
List routing bindings.
|
||||
|
||||
Options:
|
||||
|
||||
- `--agent <id>`
|
||||
- `--json`
|
||||
|
||||
#### `agents bind`
|
||||
|
||||
Add routing bindings for an agent.
|
||||
|
||||
Options:
|
||||
|
||||
- `--agent <id>`
|
||||
- `--bind <channel[:accountId]>` (repeatable)
|
||||
- `--json`
|
||||
|
||||
#### `agents unbind`
|
||||
|
||||
Remove routing bindings for an agent.
|
||||
|
||||
Options:
|
||||
|
||||
- `--agent <id>`
|
||||
- `--bind <channel[:accountId]>` (repeatable)
|
||||
- `--all`
|
||||
- `--json`
|
||||
|
||||
#### `agents delete <id>`
|
||||
|
||||
|
||||
@@ -207,3 +207,9 @@ mode, pass `--yes` to accept defaults.
|
||||
Custom providers in `models.providers` are written into `models.json` under the
|
||||
agent directory (default `~/.openclaw/agents/<agentId>/models.json`). This file
|
||||
is merged by default unless `models.mode` is set to `replace`.
|
||||
|
||||
Merge mode precedence for matching provider IDs:
|
||||
|
||||
- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win.
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`.
|
||||
- Other provider fields are refreshed from config and normalized catalog data.
|
||||
|
||||
@@ -185,6 +185,12 @@ Bindings are **deterministic** and **most-specific wins**:
|
||||
If multiple bindings match in the same tier, the first one in config order wins.
|
||||
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
|
||||
|
||||
Important account-scope detail:
|
||||
|
||||
- A binding that omits `accountId` matches the default account only.
|
||||
- Use `accountId: "*"` for a channel-wide fallback across all accounts.
|
||||
- If you later add the same binding for the same agent with an explicit account id, OpenClaw upgrades the existing channel-only binding to account-scoped instead of duplicating it.
|
||||
|
||||
## Multiple accounts / phone numbers
|
||||
|
||||
Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify
|
||||
|
||||
@@ -1002,7 +1002,12 @@
|
||||
},
|
||||
{
|
||||
"group": "Agent coordination",
|
||||
"pages": ["tools/agent-send", "tools/subagents", "tools/multi-agent-sandbox-tools"]
|
||||
"pages": [
|
||||
"tools/agent-send",
|
||||
"tools/subagents",
|
||||
"tools/acp-agents",
|
||||
"tools/multi-agent-sandbox-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Skills",
|
||||
|
||||
800
docs/experiments/plans/acp-thread-bound-agents.md
Normal file
800
docs/experiments/plans/acp-thread-bound-agents.md
Normal file
@@ -0,0 +1,800 @@
|
||||
---
|
||||
summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)"
|
||||
owner: "onutc"
|
||||
status: "draft"
|
||||
last_updated: "2026-02-25"
|
||||
title: "ACP Thread Bound Agents"
|
||||
---
|
||||
|
||||
# ACP Thread Bound Agents
|
||||
|
||||
## Overview
|
||||
|
||||
This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery.
|
||||
|
||||
Related document:
|
||||
|
||||
- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor)
|
||||
|
||||
Target user experience:
|
||||
|
||||
- a user spawns or focuses an ACP session into a thread
|
||||
- user messages in that thread route to the bound ACP session
|
||||
- agent output streams back to the same thread persona
|
||||
- session can be persistent or one shot with explicit cleanup controls
|
||||
|
||||
## Decision summary
|
||||
|
||||
Long term recommendation is a hybrid architecture:
|
||||
|
||||
- OpenClaw core owns ACP control plane concerns
|
||||
- session identity and metadata
|
||||
- thread binding and routing decisions
|
||||
- delivery invariants and duplicate suppression
|
||||
- lifecycle cleanup and recovery semantics
|
||||
- ACP runtime backend is pluggable
|
||||
- first backend is an acpx-backed plugin service
|
||||
- runtime does ACP transport, queueing, cancel, reconnect
|
||||
|
||||
OpenClaw should not reimplement ACP transport internals in core.
|
||||
OpenClaw should not rely on a pure plugin-only interception path for routing.
|
||||
|
||||
## North-star architecture (holy grail)
|
||||
|
||||
Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters.
|
||||
|
||||
Non-negotiable invariants:
|
||||
|
||||
- every ACP thread binding references a valid ACP session record
|
||||
- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`)
|
||||
- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`)
|
||||
- spawn, bind, and initial enqueue are atomic
|
||||
- command retries are idempotent (no duplicate runs or duplicate Discord outputs)
|
||||
- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects
|
||||
|
||||
Long-term ownership model:
|
||||
|
||||
- `AcpSessionManager` is the single ACP writer and orchestrator
|
||||
- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface
|
||||
- per ACP session key, manager owns one in-memory actor (serialized command execution)
|
||||
- adapters (`acpx`, future backends) are transport/runtime implementations only
|
||||
|
||||
Long-term persistence model:
|
||||
|
||||
- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir
|
||||
- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth
|
||||
- store ACP events append-only to support replay, crash recovery, and deterministic delivery
|
||||
|
||||
### Delivery strategy (bridge to holy-grail)
|
||||
|
||||
- short-term bridge
|
||||
- keep current thread binding mechanics and existing ACP config surface
|
||||
- fix metadata-gap bugs and route ACP turns through a single core ACP branch
|
||||
- add idempotency keys and fail-closed routing checks immediately
|
||||
- long-term cutover
|
||||
- move ACP source-of-truth to control-plane DB + actors
|
||||
- make bound-thread delivery purely event-projection based
|
||||
- remove legacy fallback behavior that depends on opportunistic session-entry metadata
|
||||
|
||||
## Why not pure plugin only
|
||||
|
||||
Current plugin hooks are not sufficient for end to end ACP session routing without core changes.
|
||||
|
||||
- inbound routing from thread binding resolves to a session key in core dispatch first
|
||||
- message hooks are fire-and-forget and cannot short-circuit the main reply path
|
||||
- plugin commands are good for control operations but not for replacing core per-turn dispatch flow
|
||||
|
||||
Result:
|
||||
|
||||
- ACP runtime can be pluginized
|
||||
- ACP routing branch must exist in core
|
||||
|
||||
## Existing foundation to reuse
|
||||
|
||||
Already implemented and should remain canonical:
|
||||
|
||||
- thread binding target supports `subagent` and `acp`
|
||||
- inbound thread routing override resolves by binding before normal dispatch
|
||||
- outbound thread identity via webhook in reply delivery
|
||||
- `/focus` and `/unfocus` flow with ACP target compatibility
|
||||
- persistent binding store with restore on startup
|
||||
- unbind lifecycle on archive, delete, unfocus, reset, and delete
|
||||
|
||||
This plan extends that foundation rather than replacing it.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Boundary model
|
||||
|
||||
Core (must be in OpenClaw core):
|
||||
|
||||
- ACP session-mode dispatch branch in the reply pipeline
|
||||
- delivery arbitration to avoid parent plus thread duplication
|
||||
- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration)
|
||||
- lifecycle unbind and runtime detach semantics tied to session reset/delete
|
||||
|
||||
Plugin backend (acpx implementation):
|
||||
|
||||
- ACP runtime worker supervision
|
||||
- acpx process invocation and event parsing
|
||||
- ACP command handlers (`/acp ...`) and operator UX
|
||||
- backend-specific config defaults and diagnostics
|
||||
|
||||
### Runtime ownership model
|
||||
|
||||
- one gateway process owns ACP orchestration state
|
||||
- ACP execution runs in supervised child processes via acpx backend
|
||||
- process strategy is long lived per active ACP session key, not per message
|
||||
|
||||
This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable.
|
||||
|
||||
### Core runtime contract
|
||||
|
||||
Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic:
|
||||
|
||||
```ts
|
||||
export type AcpRuntimePromptMode = "prompt" | "steer";
|
||||
|
||||
export type AcpRuntimeHandle = {
|
||||
sessionKey: string;
|
||||
backend: string;
|
||||
runtimeSessionName: string;
|
||||
};
|
||||
|
||||
export type AcpRuntimeEvent =
|
||||
| { type: "text_delta"; stream: "output" | "thought"; text: string }
|
||||
| { type: "tool_call"; name: string; argumentsText: string }
|
||||
| { type: "done"; usage?: Record<string, number> }
|
||||
| { type: "error"; code: string; message: string; retryable?: boolean };
|
||||
|
||||
export interface AcpRuntime {
|
||||
ensureSession(input: {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
idempotencyKey: string;
|
||||
}): Promise<AcpRuntimeHandle>;
|
||||
|
||||
submit(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
mode: AcpRuntimePromptMode;
|
||||
idempotencyKey: string;
|
||||
}): Promise<{ runtimeRunId: string }>;
|
||||
|
||||
stream(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
runtimeRunId: string;
|
||||
onEvent: (event: AcpRuntimeEvent) => Promise<void> | void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void>;
|
||||
|
||||
cancel(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
runtimeRunId?: string;
|
||||
reason?: string;
|
||||
idempotencyKey: string;
|
||||
}): Promise<void>;
|
||||
|
||||
close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise<void>;
|
||||
|
||||
health?(): Promise<{ ok: boolean; details?: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
Implementation detail:
|
||||
|
||||
- first backend: `AcpxRuntime` shipped as a plugin service
|
||||
- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available
|
||||
|
||||
### Control-plane data model and persistence
|
||||
|
||||
Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery:
|
||||
|
||||
- `acp_sessions`
|
||||
- `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error`
|
||||
- `acp_runs`
|
||||
- `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message`
|
||||
- `acp_bindings`
|
||||
- `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at`
|
||||
- `acp_events`
|
||||
- `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at`
|
||||
- `acp_delivery_checkpoint`
|
||||
- `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at`
|
||||
- `acp_idempotency`
|
||||
- `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)`
|
||||
|
||||
```ts
|
||||
export type AcpSessionMeta = {
|
||||
backend: string;
|
||||
agent: string;
|
||||
runtimeSessionName: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
state: "idle" | "running" | "error";
|
||||
lastActivityAt: number;
|
||||
lastError?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Storage rules:
|
||||
|
||||
- keep `SessionEntry.acp` as a compatibility projection during migration
|
||||
- process ids and sockets stay in memory only
|
||||
- durable lifecycle and run status live in ACP DB, not generic session JSON
|
||||
- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints
|
||||
|
||||
### Routing and delivery
|
||||
|
||||
Inbound:
|
||||
|
||||
- keep current thread binding lookup as first routing step
|
||||
- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig`
|
||||
- explicit `/acp steer` command uses `mode: "steer"`
|
||||
|
||||
Outbound:
|
||||
|
||||
- ACP event stream is normalized to OpenClaw reply chunks
|
||||
- delivery target is resolved through existing bound destination path
|
||||
- when a bound thread is active for that session turn, parent channel completion is suppressed
|
||||
|
||||
Streaming policy:
|
||||
|
||||
- stream partial output with coalescing window
|
||||
- configurable min interval and max chunk bytes to stay under Discord rate limits
|
||||
- final message always emitted on completion or failure
|
||||
|
||||
### State machines and transaction boundaries
|
||||
|
||||
Session state machine:
|
||||
|
||||
- `creating -> idle -> running -> idle`
|
||||
- `running -> cancelling -> idle | error`
|
||||
- `idle -> closed`
|
||||
- `error -> idle | closed`
|
||||
|
||||
Run state machine:
|
||||
|
||||
- `queued -> running -> completed`
|
||||
- `running -> failed | cancelled`
|
||||
- `queued -> cancelled`
|
||||
|
||||
Required transaction boundaries:
|
||||
|
||||
- spawn transaction
|
||||
- create ACP session row
|
||||
- create/update ACP thread binding row
|
||||
- enqueue initial run row
|
||||
- close transaction
|
||||
- mark session closed
|
||||
- delete/expire binding rows
|
||||
- write final close event
|
||||
- cancel transaction
|
||||
- mark target run cancelling/cancelled with idempotency key
|
||||
|
||||
No partial success is allowed across these boundaries.
|
||||
|
||||
### Per-session actor model
|
||||
|
||||
`AcpSessionManager` runs one actor per ACP session key:
|
||||
|
||||
- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects
|
||||
- actor owns runtime handle hydration and runtime adapter process lifecycle for that session
|
||||
- actor writes run events in-order (`seq`) before any Discord delivery
|
||||
- actor updates delivery checkpoints after successful outbound send
|
||||
|
||||
This removes cross-turn races and prevents duplicate or out-of-order thread output.
|
||||
|
||||
### Idempotency and delivery projection
|
||||
|
||||
All external ACP actions must carry idempotency keys:
|
||||
|
||||
- spawn idempotency key
|
||||
- prompt/steer idempotency key
|
||||
- cancel idempotency key
|
||||
- close idempotency key
|
||||
|
||||
Delivery rules:
|
||||
|
||||
- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint`
|
||||
- retries resume from checkpoint without re-sending already delivered chunks
|
||||
- final reply emission is exactly-once per run from projection logic
|
||||
|
||||
### Recovery and self-healing
|
||||
|
||||
On gateway start:
|
||||
|
||||
- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`)
|
||||
- recreate actors lazily on first inbound event or eagerly under configured cap
|
||||
- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter
|
||||
|
||||
On inbound Discord thread message:
|
||||
|
||||
- if binding exists but ACP session is missing, fail closed with explicit stale-binding message
|
||||
- optionally auto-unbind stale binding after operator-safe validation
|
||||
- never silently route stale ACP bindings to normal LLM path
|
||||
|
||||
### Lifecycle and safety
|
||||
|
||||
Supported operations:
|
||||
|
||||
- cancel current run: `/acp cancel`
|
||||
- unbind thread: `/unfocus`
|
||||
- close ACP session: `/acp close`
|
||||
- auto close idle sessions by effective TTL
|
||||
|
||||
TTL policy:
|
||||
|
||||
- effective TTL is minimum of
|
||||
- global/session TTL
|
||||
- Discord thread binding TTL
|
||||
- ACP runtime owner TTL
|
||||
|
||||
Safety controls:
|
||||
|
||||
- allowlist ACP agents by name
|
||||
- restrict workspace roots for ACP sessions
|
||||
- env allowlist passthrough
|
||||
- max concurrent ACP sessions per account and globally
|
||||
- bounded restart backoff for runtime crashes
|
||||
|
||||
## Config surface
|
||||
|
||||
Core keys:
|
||||
|
||||
- `acp.enabled`
|
||||
- `acp.dispatch.enabled` (independent ACP routing kill switch)
|
||||
- `acp.backend` (default `acpx`)
|
||||
- `acp.defaultAgent`
|
||||
- `acp.allowedAgents[]`
|
||||
- `acp.maxConcurrentSessions`
|
||||
- `acp.stream.coalesceIdleMs`
|
||||
- `acp.stream.maxChunkChars`
|
||||
- `acp.runtime.ttlMinutes`
|
||||
- `acp.controlPlane.store` (`sqlite` default)
|
||||
- `acp.controlPlane.storePath`
|
||||
- `acp.controlPlane.recovery.eagerActors`
|
||||
- `acp.controlPlane.recovery.reconcileRunningAfterMs`
|
||||
- `acp.controlPlane.checkpoint.flushEveryEvents`
|
||||
- `acp.controlPlane.checkpoint.flushEveryMs`
|
||||
- `acp.idempotency.ttlHours`
|
||||
- `channels.discord.threadBindings.spawnAcpSessions`
|
||||
|
||||
Plugin/backend keys (acpx plugin section):
|
||||
|
||||
- backend command/path overrides
|
||||
- backend env allowlist
|
||||
- backend per-agent presets
|
||||
- backend startup/stop timeouts
|
||||
- backend max inflight runs per session
|
||||
|
||||
## Implementation specification
|
||||
|
||||
### Control-plane modules (new)
|
||||
|
||||
Add dedicated ACP control-plane modules in core:
|
||||
|
||||
- `src/acp/control-plane/manager.ts`
|
||||
- owns ACP actors, lifecycle transitions, command serialization
|
||||
- `src/acp/control-plane/store.ts`
|
||||
- SQLite schema management, transactions, query helpers
|
||||
- `src/acp/control-plane/events.ts`
|
||||
- typed ACP event definitions and serialization
|
||||
- `src/acp/control-plane/checkpoint.ts`
|
||||
- durable delivery checkpoints and replay cursors
|
||||
- `src/acp/control-plane/idempotency.ts`
|
||||
- idempotency key reservation and response replay
|
||||
- `src/acp/control-plane/recovery.ts`
|
||||
- boot-time reconciliation and actor rehydrate plan
|
||||
|
||||
Compatibility bridge modules:
|
||||
|
||||
- `src/acp/runtime/session-meta.ts`
|
||||
- remains temporarily for projection into `SessionEntry.acp`
|
||||
- must stop being source-of-truth after migration cutover
|
||||
|
||||
### Required invariants (must enforce in code)
|
||||
|
||||
- ACP session creation and thread bind are atomic (single transaction)
|
||||
- there is at most one active run per ACP session actor at a time
|
||||
- event `seq` is strictly increasing per run
|
||||
- delivery checkpoint never advances past last committed event
|
||||
- idempotency replay returns previous success payload for duplicate command keys
|
||||
- stale/missing ACP metadata cannot route into normal non-ACP reply path
|
||||
|
||||
### Core touchpoints
|
||||
|
||||
Core files to change:
|
||||
|
||||
- `src/auto-reply/reply/dispatch-from-config.ts`
|
||||
- ACP branch calls `AcpSessionManager.submit` and event-projection delivery
|
||||
- remove direct ACP fallback that bypasses control-plane invariants
|
||||
- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary)
|
||||
- expose normalized routing keys and idempotency seeds for ACP control plane
|
||||
- `src/config/sessions/types.ts`
|
||||
- keep `SessionEntry.acp` as projection-only compatibility field
|
||||
- `src/gateway/server-methods/sessions.ts`
|
||||
- reset/delete/archive must call ACP manager close/unbind transaction path
|
||||
- `src/infra/outbound/bound-delivery-router.ts`
|
||||
- enforce fail-closed destination behavior for ACP bound session turns
|
||||
- `src/discord/monitor/thread-bindings.ts`
|
||||
- add ACP stale-binding validation helpers wired to control-plane lookups
|
||||
- `src/auto-reply/reply/commands-acp.ts`
|
||||
- route spawn/cancel/close/steer through ACP manager APIs
|
||||
- `src/agents/acp-spawn.ts`
|
||||
- stop ad-hoc metadata writes; call ACP manager spawn transaction
|
||||
- `src/plugin-sdk/**` and plugin runtime bridge
|
||||
- expose ACP backend registration and health semantics cleanly
|
||||
|
||||
Core files explicitly not replaced:
|
||||
|
||||
- `src/discord/monitor/message-handler.preflight.ts`
|
||||
- keep thread binding override behavior as the canonical session-key resolver
|
||||
|
||||
### ACP runtime registry API
|
||||
|
||||
Add a core registry module:
|
||||
|
||||
- `src/acp/runtime/registry.ts`
|
||||
|
||||
Required API:
|
||||
|
||||
```ts
|
||||
export type AcpRuntimeBackend = {
|
||||
id: string;
|
||||
runtime: AcpRuntime;
|
||||
healthy?: () => boolean;
|
||||
};
|
||||
|
||||
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void;
|
||||
export function unregisterAcpRuntimeBackend(id: string): void;
|
||||
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null;
|
||||
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend;
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable
|
||||
- plugin service registers backend on `start` and unregisters on `stop`
|
||||
- runtime lookups are read-only and process-local
|
||||
|
||||
### acpx runtime plugin contract (implementation detail)
|
||||
|
||||
For the first production backend (`extensions/acpx`), OpenClaw and acpx are
|
||||
connected with a strict command contract:
|
||||
|
||||
- backend id: `acpx`
|
||||
- plugin service id: `acpx-runtime`
|
||||
- runtime handle encoding: `runtimeSessionName = acpx:v1:<base64url(json)>`
|
||||
- encoded payload fields:
|
||||
- `name` (acpx named session; uses OpenClaw `sessionKey`)
|
||||
- `agent` (acpx agent command)
|
||||
- `cwd` (session workspace root)
|
||||
- `mode` (`persistent | oneshot`)
|
||||
|
||||
Command mapping:
|
||||
|
||||
- ensure session:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> sessions ensure --name <name>`
|
||||
- prompt turn:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> prompt --session <name> --file -`
|
||||
- cancel:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> cancel --session <name>`
|
||||
- close:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> sessions close <name>`
|
||||
|
||||
Streaming:
|
||||
|
||||
- OpenClaw consumes ndjson events from `acpx --format json --json-strict`
|
||||
- `text` => `text_delta/output`
|
||||
- `thought` => `text_delta/thought`
|
||||
- `tool_call` => `tool_call`
|
||||
- `done` => `done`
|
||||
- `error` => `error`
|
||||
|
||||
### Session schema patch
|
||||
|
||||
Patch `SessionEntry` in `src/config/sessions/types.ts`:
|
||||
|
||||
```ts
|
||||
type SessionAcpMeta = {
|
||||
backend: string;
|
||||
agent: string;
|
||||
runtimeSessionName: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
state: "idle" | "running" | "error";
|
||||
lastActivityAt: number;
|
||||
lastError?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Persisted field:
|
||||
|
||||
- `SessionEntry.acp?: SessionAcpMeta`
|
||||
|
||||
Migration rules:
|
||||
|
||||
- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth)
|
||||
- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp`
|
||||
- phase C: migration command backfills missing ACP rows from valid legacy entries
|
||||
- phase D: remove fallback-read and keep projection optional for UX only
|
||||
- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched
|
||||
|
||||
### Error contract
|
||||
|
||||
Add stable ACP error codes and user-facing messages:
|
||||
|
||||
- `ACP_BACKEND_MISSING`
|
||||
- message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.`
|
||||
- `ACP_BACKEND_UNAVAILABLE`
|
||||
- message: `ACP runtime backend is currently unavailable. Try again in a moment.`
|
||||
- `ACP_SESSION_INIT_FAILED`
|
||||
- message: `Could not initialize ACP session runtime.`
|
||||
- `ACP_TURN_FAILED`
|
||||
- message: `ACP turn failed before completion.`
|
||||
|
||||
Rules:
|
||||
|
||||
- return actionable user-safe message in-thread
|
||||
- log detailed backend/system error only in runtime logs
|
||||
- never silently fall back to normal LLM path when ACP routing was explicitly selected
|
||||
|
||||
### Duplicate delivery arbitration
|
||||
|
||||
Single routing rule for ACP bound turns:
|
||||
|
||||
- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread
|
||||
- do not also send to parent channel for the same turn
|
||||
- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback)
|
||||
- if no active binding exists, use normal session destination behavior
|
||||
|
||||
### Observability and operational readiness
|
||||
|
||||
Required metrics:
|
||||
|
||||
- ACP spawn success/failure count by backend and error code
|
||||
- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time)
|
||||
- ACP actor restart count and restart reason
|
||||
- stale-binding detection count
|
||||
- idempotency replay hit rate
|
||||
- Discord delivery retry and rate-limit counters
|
||||
|
||||
Required logs:
|
||||
|
||||
- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey`
|
||||
- explicit state transition logs for session and run state machines
|
||||
- adapter command logs with redaction-safe arguments and exit summary
|
||||
|
||||
Required diagnostics:
|
||||
|
||||
- `/acp sessions` includes state, active run, last error, and binding status
|
||||
- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings
|
||||
|
||||
### Config precedence and effective values
|
||||
|
||||
ACP enablement precedence:
|
||||
|
||||
- account override: `channels.discord.accounts.<id>.threadBindings.spawnAcpSessions`
|
||||
- channel override: `channels.discord.threadBindings.spawnAcpSessions`
|
||||
- global ACP gate: `acp.enabled`
|
||||
- dispatch gate: `acp.dispatch.enabled`
|
||||
- backend availability: registered backend for `acp.backend`
|
||||
|
||||
Auto-enable behavior:
|
||||
|
||||
- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or
|
||||
`acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true`
|
||||
unless denylisted or explicitly disabled
|
||||
|
||||
TTL effective value:
|
||||
|
||||
- `min(session ttl, discord thread binding ttl, acp runtime ttl)`
|
||||
|
||||
### Test map
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `src/acp/runtime/registry.test.ts` (new)
|
||||
- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new)
|
||||
- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases)
|
||||
- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence)
|
||||
|
||||
Integration tests:
|
||||
|
||||
- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior)
|
||||
- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity)
|
||||
- acpx plugin runtime tests in backend package (service register/start/stop + event normalization)
|
||||
|
||||
Gateway e2e tests:
|
||||
|
||||
- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage)
|
||||
- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery
|
||||
|
||||
### Rollout guard
|
||||
|
||||
Add independent ACP dispatch kill switch:
|
||||
|
||||
- `acp.dispatch.enabled` default `false` for first release
|
||||
- when disabled:
|
||||
- ACP spawn/focus control commands may still bind sessions
|
||||
- ACP dispatch path does not activate
|
||||
- user receives explicit message that ACP dispatch is disabled by policy
|
||||
- after canary validation, default can be flipped to `true` in a later release
|
||||
|
||||
## Command and UX plan
|
||||
|
||||
### New commands
|
||||
|
||||
- `/acp spawn <agent-id> [--mode persistent|oneshot] [--thread auto|here|off]`
|
||||
- `/acp cancel [session]`
|
||||
- `/acp steer <instruction>`
|
||||
- `/acp close [session]`
|
||||
- `/acp sessions`
|
||||
|
||||
### Existing command compatibility
|
||||
|
||||
- `/focus <sessionKey>` continues to support ACP targets
|
||||
- `/unfocus` keeps current semantics
|
||||
- `/session ttl` remains the top level TTL override
|
||||
|
||||
## Phased rollout
|
||||
|
||||
### Phase 0 ADR and schema freeze
|
||||
|
||||
- ship ADR for ACP control-plane ownership and adapter boundaries
|
||||
- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`)
|
||||
- define stable ACP error codes, event contract, and state-transition guards
|
||||
|
||||
### Phase 1 Control-plane foundation in core
|
||||
|
||||
- implement `AcpSessionManager` and per-session actor runtime
|
||||
- implement ACP SQLite store and transaction helpers
|
||||
- implement idempotency store and replay helpers
|
||||
- implement event append + delivery checkpoint modules
|
||||
- wire spawn/cancel/close APIs to manager with transactional guarantees
|
||||
|
||||
### Phase 2 Core routing and lifecycle integration
|
||||
|
||||
- route thread-bound ACP turns from dispatch pipeline into ACP manager
|
||||
- enforce fail-closed routing when ACP binding/session invariants fail
|
||||
- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions
|
||||
- add stale-binding detection and optional auto-unbind policy
|
||||
|
||||
### Phase 3 acpx backend adapter/plugin
|
||||
|
||||
- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`)
|
||||
- add backend health checks and startup/teardown registration
|
||||
- normalize acpx ndjson events into ACP runtime events
|
||||
- enforce backend timeouts, process supervision, and restart/backoff policy
|
||||
|
||||
### Phase 4 Delivery projection and channel UX (Discord first)
|
||||
|
||||
- implement event-driven channel projection with checkpoint resume (Discord first)
|
||||
- coalesce streaming chunks with rate-limit aware flush policy
|
||||
- guarantee exactly-once final completion message per run
|
||||
- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions`
|
||||
|
||||
### Phase 5 Migration and cutover
|
||||
|
||||
- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth
|
||||
- add migration utility for legacy ACP metadata rows
|
||||
- flip read path to ACP SQLite primary
|
||||
- remove legacy fallback routing that depends on missing `SessionEntry.acp`
|
||||
|
||||
### Phase 6 Hardening, SLOs, and scale limits
|
||||
|
||||
- enforce concurrency limits (global/account/session), queue policies, and timeout budgets
|
||||
- add full telemetry, dashboards, and alert thresholds
|
||||
- chaos-test crash recovery and duplicate-delivery suppression
|
||||
- publish runbook for backend outage, DB corruption, and stale-binding remediation
|
||||
|
||||
### Full implementation checklist
|
||||
|
||||
- core control-plane modules and tests
|
||||
- DB migrations and rollback plan
|
||||
- ACP manager API integration across dispatch and commands
|
||||
- adapter registration interface in plugin runtime bridge
|
||||
- acpx adapter implementation and tests
|
||||
- thread-capable channel delivery projection logic with checkpoint replay (Discord first)
|
||||
- lifecycle hooks for reset/delete/archive/unfocus
|
||||
- stale-binding detector and operator-facing diagnostics
|
||||
- config validation and precedence tests for all new ACP keys
|
||||
- operational docs and troubleshooting runbook
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close)
|
||||
- ACP state-machine transition guards for sessions and runs
|
||||
- idempotency reservation/replay semantics across all ACP commands
|
||||
- per-session actor serialization and queue ordering
|
||||
- acpx event parser and chunk coalescer
|
||||
- runtime supervisor restart and backoff policy
|
||||
- config precedence and effective TTL calculation
|
||||
- core ACP routing branch selection and fail-closed behavior when backend/session is invalid
|
||||
|
||||
Integration tests:
|
||||
|
||||
- fake ACP adapter process for deterministic streaming and cancel behavior
|
||||
- ACP manager + dispatch integration with transactional persistence
|
||||
- thread-bound inbound routing to ACP session key
|
||||
- thread-bound outbound delivery suppresses parent channel duplication
|
||||
- checkpoint replay recovers after delivery failure and resumes from last event
|
||||
- plugin service registration and teardown of ACP runtime backend
|
||||
|
||||
Gateway e2e tests:
|
||||
|
||||
- spawn ACP with thread, exchange multi-turn prompts, unfocus
|
||||
- gateway restart with persisted ACP DB and bindings, then continue same session
|
||||
- concurrent ACP sessions in multiple threads have no cross-talk
|
||||
- duplicate command retries (same idempotency key) do not create duplicate runs or replies
|
||||
- stale-binding scenario yields explicit error and optional auto-clean behavior
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- Duplicate deliveries during transition
|
||||
- Mitigation: single destination resolver and idempotent event checkpoint
|
||||
- Runtime process churn under load
|
||||
- Mitigation: long lived per session owners + concurrency caps + backoff
|
||||
- Plugin absent or misconfigured
|
||||
- Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path)
|
||||
- Config confusion between subagent and ACP gates
|
||||
- Mitigation: explicit ACP keys and command feedback that includes effective policy source
|
||||
- Control-plane store corruption or migration bugs
|
||||
- Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics
|
||||
- Actor deadlocks or mailbox starvation
|
||||
- Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry
|
||||
|
||||
## Acceptance checklist
|
||||
|
||||
- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord)
|
||||
- all thread messages route to bound ACP session only
|
||||
- ACP outputs appear in the same thread identity with streaming or batches
|
||||
- no duplicate output in parent channel for bound turns
|
||||
- spawn+bind+initial enqueue are atomic in persistent store
|
||||
- ACP command retries are idempotent and do not duplicate runs or outputs
|
||||
- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup
|
||||
- crash restart preserves mapping and resumes multi turn continuity
|
||||
- concurrent thread bound ACP sessions work independently
|
||||
- ACP backend missing state produces clear actionable error
|
||||
- stale bindings are detected and surfaced explicitly (with optional safe auto-clean)
|
||||
- control-plane metrics and diagnostics are available for operators
|
||||
- new unit, integration, and e2e coverage passes
|
||||
|
||||
## Addendum: targeted refactors for current implementation (status)
|
||||
|
||||
These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands.
|
||||
|
||||
### 1) Centralize ACP dispatch policy evaluation (completed)
|
||||
|
||||
- implemented via shared ACP policy helpers in `src/acp/policy.ts`
|
||||
- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic
|
||||
|
||||
### 2) Split ACP command handler by subcommand domain (completed)
|
||||
|
||||
- `src/auto-reply/reply/commands-acp.ts` is now a thin router
|
||||
- subcommand behavior is split into:
|
||||
- `src/auto-reply/reply/commands-acp/lifecycle.ts`
|
||||
- `src/auto-reply/reply/commands-acp/runtime-options.ts`
|
||||
- `src/auto-reply/reply/commands-acp/diagnostics.ts`
|
||||
- shared helpers in `src/auto-reply/reply/commands-acp/shared.ts`
|
||||
|
||||
### 3) Split ACP session manager by responsibility (completed)
|
||||
|
||||
- manager is split into:
|
||||
- `src/acp/control-plane/manager.ts` (public facade + singleton)
|
||||
- `src/acp/control-plane/manager.core.ts` (manager implementation)
|
||||
- `src/acp/control-plane/manager.types.ts` (manager types/deps)
|
||||
- `src/acp/control-plane/manager.utils.ts` (normalization + helper functions)
|
||||
|
||||
### 4) Optional acpx runtime adapter cleanup
|
||||
|
||||
- `extensions/acpx/src/runtime.ts` can be split into:
|
||||
- process execution/supervision
|
||||
- ndjson event parsing/normalization
|
||||
- runtime API surface (`submit`, `cancel`, `close`, etc.)
|
||||
- improves testability and makes backend behavior easier to audit
|
||||
96
docs/experiments/plans/acp-unified-streaming-refactor.md
Normal file
96
docs/experiments/plans/acp-unified-streaming-refactor.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP"
|
||||
owner: "onutc"
|
||||
status: "draft"
|
||||
last_updated: "2026-02-25"
|
||||
title: "Unified Runtime Streaming Refactor Plan"
|
||||
---
|
||||
|
||||
# Unified Runtime Streaming Refactor Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior.
|
||||
|
||||
## Why this exists
|
||||
|
||||
- Current behavior is split across multiple runtime-specific shaping paths.
|
||||
- Formatting/coalescing bugs can be fixed in one path but remain in others.
|
||||
- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about.
|
||||
|
||||
## Target architecture
|
||||
|
||||
Single pipeline, runtime-specific adapters:
|
||||
|
||||
1. Runtime adapters emit canonical events only.
|
||||
2. Shared stream assembler coalesces and finalizes text/tool/status events.
|
||||
3. Shared channel projector applies channel-specific chunking/formatting once.
|
||||
4. Shared delivery ledger enforces idempotent send/replay semantics.
|
||||
5. Outbound channel adapter executes sends and records delivery checkpoints.
|
||||
|
||||
Canonical event contract:
|
||||
|
||||
- `turn_started`
|
||||
- `text_delta`
|
||||
- `block_final`
|
||||
- `tool_started`
|
||||
- `tool_finished`
|
||||
- `status`
|
||||
- `turn_completed`
|
||||
- `turn_failed`
|
||||
- `turn_cancelled`
|
||||
|
||||
## Workstreams
|
||||
|
||||
### 1) Canonical streaming contract
|
||||
|
||||
- Define strict event schema + validation in core.
|
||||
- Add adapter contract tests to guarantee each runtime emits compatible events.
|
||||
- Reject malformed runtime events early and surface structured diagnostics.
|
||||
|
||||
### 2) Shared stream processor
|
||||
|
||||
- Replace runtime-specific coalescer/projector logic with one processor.
|
||||
- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush.
|
||||
- Move ACP/main/subagent config resolution into one helper to prevent drift.
|
||||
|
||||
### 3) Shared channel projection
|
||||
|
||||
- Keep channel adapters dumb: accept finalized blocks and send.
|
||||
- Move Discord-specific chunking quirks to channel projector only.
|
||||
- Keep pipeline channel-agnostic before projection.
|
||||
|
||||
### 4) Delivery ledger + replay
|
||||
|
||||
- Add per-turn/per-chunk delivery IDs.
|
||||
- Record checkpoints before and after physical send.
|
||||
- On restart, replay pending chunks idempotently and avoid duplicates.
|
||||
|
||||
### 5) Migration and cutover
|
||||
|
||||
- Phase 1: shadow mode (new pipeline computes output but old path sends; compare).
|
||||
- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk).
|
||||
- Phase 3: delete legacy runtime-specific streaming code.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No changes to ACP policy/permissions model in this refactor.
|
||||
- No channel-specific feature expansion outside projection compatibility fixes.
|
||||
- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity).
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- Risk: behavioral regressions in existing main/subagent paths.
|
||||
Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests.
|
||||
- Risk: duplicate sends during crash recovery.
|
||||
Mitigation: durable delivery IDs + idempotent replay in delivery adapter.
|
||||
- Risk: runtime adapters diverge again.
|
||||
Mitigation: required shared contract test suite for all adapters.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- All runtimes pass shared streaming contract tests.
|
||||
- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas.
|
||||
- Crash/restart replay sends no duplicate chunk for the same delivery ID.
|
||||
- Legacy ACP projector/coalescer path is removed.
|
||||
- Streaming config resolution is shared and runtime-independent.
|
||||
@@ -628,4 +628,4 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
- If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`.
|
||||
- Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format.
|
||||
- Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
|
||||
- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
|
||||
- See [Providers](/providers) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
|
||||
|
||||
@@ -505,6 +505,9 @@ Run multiple accounts per channel (each with its own `accountId`):
|
||||
- Env tokens only apply to the **default** account.
|
||||
- Base channel settings apply to all accounts unless overridden per account.
|
||||
- Use `bindings[].match.accountId` to route each account to a different agent.
|
||||
- If you add a non-default account via `openclaw channels add` (or channel onboarding) while still on a single-account top-level channel config, OpenClaw moves account-scoped top-level single-account values into `channels.<channel>.accounts.default` first so the original account keeps working.
|
||||
- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional.
|
||||
- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into `accounts.default` when named accounts exist but `default` is missing.
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
@@ -1741,6 +1744,10 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
|
||||
|
||||
- Use `authHeader: true` + `headers` for custom auth needs.
|
||||
- Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`).
|
||||
- Merge precedence for matching provider IDs:
|
||||
- Non-empty agent `models.json` `apiKey`/`baseUrl` win.
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
|
||||
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
|
||||
|
||||
### Provider examples
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ Current migrations:
|
||||
- `routing.agentToAgent` → `tools.agentToAgent`
|
||||
- `routing.transcribeAudio` → `tools.media.audio.models`
|
||||
- `bindings[].match.accountID` → `bindings[].match.accountId`
|
||||
- For channels with named `accounts` but missing `accounts.default`, move account-scoped top-level single-account channel values into `channels.<channel>.accounts.default` when present
|
||||
- `identity` → `agents.list[].identity`
|
||||
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
|
||||
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
||||
|
||||
@@ -202,7 +202,9 @@ Use this when auditing access or deciding what to back up:
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**: `~/.openclaw/credentials/<channel>-allowFrom.json`
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
|
||||
@@ -488,7 +490,7 @@ If you run multiple accounts on the same channel, use `per-account-channel-peer`
|
||||
OpenClaw has two separate “who can trigger me?” layers:
|
||||
|
||||
- **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
|
||||
- When `dmPolicy="pairing"`, approvals are written to `~/.openclaw/credentials/<channel>-allowFrom.json` (merged with config allowlists).
|
||||
- When `dmPolicy="pairing"`, approvals are written to the account-scoped pairing allowlist store under `~/.openclaw/credentials/` (`<channel>-allowFrom.json` for default account, `<channel>-<accountId>-allowFrom.json` for non-default accounts), merged with config allowlists.
|
||||
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all.
|
||||
- Common patterns:
|
||||
- `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
|
||||
|
||||
@@ -336,6 +336,11 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con
|
||||
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
|
||||
- Plugins (custom extension load + registry smoke): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
|
||||
|
||||
Manual ACP plain-language thread smoke (not CI):
|
||||
|
||||
- `bun scripts/dev/discord-acp-plain-language-smoke.ts --channel <discord-channel-id> ...`
|
||||
- Keep this script for regression/debug workflows. It may be needed again for ACP thread routing validation, so do not delete it.
|
||||
|
||||
Useful env vars:
|
||||
|
||||
- `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw`
|
||||
|
||||
@@ -26,6 +26,7 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing)
|
||||
## Requirements
|
||||
|
||||
- Docker Desktop (or Docker Engine) + Docker Compose v2
|
||||
- At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137)
|
||||
- Enough disk for images + logs
|
||||
|
||||
## Containerized Gateway (Docker Compose)
|
||||
|
||||
@@ -114,10 +114,11 @@ gcloud services enable compute.googleapis.com
|
||||
|
||||
**Machine types:**
|
||||
|
||||
| Type | Specs | Cost | Notes |
|
||||
| -------- | ------------------------ | ------------------ | ------------------ |
|
||||
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended |
|
||||
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load |
|
||||
| Type | Specs | Cost | Notes |
|
||||
| --------- | ------------------------ | ------------------ | -------------------------------------------- |
|
||||
| e2-medium | 2 vCPU, 4GB RAM | ~$25/mo | Most reliable for local Docker builds |
|
||||
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Minimum recommended for Docker build |
|
||||
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | Often fails with Docker build OOM (exit 137) |
|
||||
|
||||
**CLI:**
|
||||
|
||||
@@ -350,6 +351,16 @@ docker compose build
|
||||
docker compose up -d openclaw-gateway
|
||||
```
|
||||
|
||||
If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds.
|
||||
|
||||
When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing:
|
||||
|
||||
```bash
|
||||
docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins '["http://127.0.0.1:18789"]' --strict-json
|
||||
```
|
||||
|
||||
If you changed the gateway port, replace `18789` with your configured port.
|
||||
|
||||
Verify binaries:
|
||||
|
||||
```bash
|
||||
@@ -394,7 +405,20 @@ Open in your browser:
|
||||
|
||||
`http://127.0.0.1:18789/`
|
||||
|
||||
Paste your gateway token.
|
||||
Fetch a fresh tokenized dashboard link:
|
||||
|
||||
```bash
|
||||
docker compose run --rm openclaw-cli dashboard --no-open
|
||||
```
|
||||
|
||||
Paste the token from that URL.
|
||||
|
||||
If Control UI shows `unauthorized` or `disconnected (1008): pairing required`, approve the browser device:
|
||||
|
||||
```bash
|
||||
docker compose run --rm openclaw-cli devices list
|
||||
docker compose run --rm openclaw-cli devices approve <requestId>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -449,7 +473,7 @@ Ensure your account has the required IAM permissions (Compute OS Login or Comput
|
||||
|
||||
**Out of memory (OOM)**
|
||||
|
||||
If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium:
|
||||
If Docker build fails with `Killed` and `exit code 137`, the VM was OOM-killed. Upgrade to e2-small (minimum) or e2-medium (recommended for reliable local builds):
|
||||
|
||||
```bash
|
||||
# Stop the VM first
|
||||
|
||||
@@ -34,17 +34,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.2.25 \
|
||||
APP_VERSION=2026.2.26 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.25.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.26.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.26.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.2.25 \
|
||||
APP_VERSION=2026.2.26 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.25.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.26.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.25.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.26.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.2.25.zip` (and `OpenClaw-2026.2.25.dSYM.zip`) to the GitHub release for tag `v2026.2.25`.
|
||||
- Upload `OpenClaw-2026.2.26.zip` (and `OpenClaw-2026.2.26.dSYM.zip`) to the GitHub release for tag `v2026.2.26`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -34,6 +34,7 @@ Security trust model:
|
||||
|
||||
- By default, OpenClaw is a personal agent: one trusted operator boundary.
|
||||
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
|
||||
|
||||
</Step>
|
||||
<Step title="Local vs Remote">
|
||||
<Frame>
|
||||
@@ -50,9 +51,11 @@ Where does the **Gateway** run?
|
||||
|
||||
<Tip>
|
||||
**Gateway auth tip:**
|
||||
|
||||
- The wizard now generates a **token** even for loopback, so local WS clients must authenticate.
|
||||
- If you disable auth, any local process can connect; use that only on fully trusted machines.
|
||||
- Use a **token** for multi‑machine access or non‑loopback binds.
|
||||
|
||||
</Tip>
|
||||
</Step>
|
||||
<Step title="Permissions">
|
||||
|
||||
@@ -130,7 +130,9 @@ Use this when debugging auth or deciding what to back up:
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**: `~/.openclaw/credentials/<channel>-allowFrom.json`
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
More detail: [Security](/gateway/security#credential-storage-map).
|
||||
|
||||
265
docs/tools/acp-agents.md
Normal file
265
docs/tools/acp-agents.md
Normal file
@@ -0,0 +1,265 @@
|
||||
---
|
||||
summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents"
|
||||
read_when:
|
||||
- Running coding harnesses through ACP
|
||||
- Setting up thread-bound ACP sessions on thread-capable channels
|
||||
- Troubleshooting ACP backend and plugin wiring
|
||||
title: "ACP Agents"
|
||||
---
|
||||
|
||||
# ACP agents
|
||||
|
||||
ACP sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin.
|
||||
|
||||
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
|
||||
|
||||
## Quick start for humans
|
||||
|
||||
Examples of natural requests:
|
||||
|
||||
- "Start a persistent Codex session in a thread here and keep it focused."
|
||||
- "Run this as a one-shot Claude Code ACP session and summarize the result."
|
||||
- "Use Gemini CLI for this task in a thread, then keep follow-ups in that same thread."
|
||||
|
||||
What OpenClaw should do:
|
||||
|
||||
1. Pick `runtime: "acp"`.
|
||||
2. Resolve the requested harness target (`agentId`, for example `codex`).
|
||||
3. If thread binding is requested and the current channel supports it, bind the ACP session to the thread.
|
||||
4. Route follow-up thread messages to that same ACP session until unfocused/closed/expired.
|
||||
|
||||
## ACP versus sub-agents
|
||||
|
||||
Use ACP when you want an external harness runtime. Use sub-agents when you want OpenClaw-native delegated runs.
|
||||
|
||||
| Area | ACP session | Sub-agent run |
|
||||
| ------------- | ------------------------------------- | ---------------------------------- |
|
||||
| Runtime | ACP backend plugin (for example acpx) | OpenClaw native sub-agent runtime |
|
||||
| Session key | `agent:<agentId>:acp:<uuid>` | `agent:<agentId>:subagent:<uuid>` |
|
||||
| Main commands | `/acp ...` | `/subagents ...` |
|
||||
| Spawn tool | `sessions_spawn` with `runtime:"acp"` | `sessions_spawn` (default runtime) |
|
||||
|
||||
See also [Sub-agents](/tools/subagents).
|
||||
|
||||
## Thread-bound sessions (channel-agnostic)
|
||||
|
||||
When thread bindings are enabled for a channel adapter, ACP sessions can be bound to threads:
|
||||
|
||||
- OpenClaw binds a thread to a target ACP session.
|
||||
- Follow-up messages in that thread route to the bound ACP session.
|
||||
- ACP output is delivered back to the same thread.
|
||||
- Unfocus/close/archive/TTL expiry removes the binding.
|
||||
|
||||
Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message.
|
||||
|
||||
Required feature flags for thread-bound ACP:
|
||||
|
||||
- `acp.enabled=true`
|
||||
- `acp.dispatch.enabled=true`
|
||||
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
|
||||
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
|
||||
|
||||
### Thread supporting channels
|
||||
|
||||
- Any channel adapter that exposes session/thread binding capability.
|
||||
- Current built-in support: Discord.
|
||||
- Plugin channels can add support through the same binding interface.
|
||||
|
||||
## Start ACP sessions (interfaces)
|
||||
|
||||
### From `sessions_spawn`
|
||||
|
||||
Use `runtime: "acp"` to start an ACP session from an agent turn or tool call.
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Open the repo and summarize failing tests",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"thread": true,
|
||||
"mode": "session"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `runtime` defaults to `subagent`, so set `runtime: "acp"` explicitly for ACP sessions.
|
||||
- If `agentId` is omitted, OpenClaw uses `acp.defaultAgent` when configured.
|
||||
- `mode: "session"` requires `thread: true` to keep a persistent bound conversation.
|
||||
|
||||
Interface details:
|
||||
|
||||
- `task` (required): initial prompt sent to the ACP session.
|
||||
- `runtime` (required for ACP): must be `"acp"`.
|
||||
- `agentId` (optional): ACP target harness id. Falls back to `acp.defaultAgent` if set.
|
||||
- `thread` (optional, default `false`): request thread binding flow where supported.
|
||||
- `mode` (optional): `run` (one-shot) or `session` (persistent).
|
||||
- default is `run`
|
||||
- if `thread: true` and mode omitted, OpenClaw may default to persistent behavior per runtime path
|
||||
- `mode: "session"` requires `thread: true`
|
||||
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
|
||||
- `label` (optional): operator-facing label used in session/banner text.
|
||||
|
||||
### From `/acp` command
|
||||
|
||||
Use `/acp spawn` for explicit operator control from chat when needed.
|
||||
|
||||
```text
|
||||
/acp spawn codex --mode persistent --thread auto
|
||||
/acp spawn codex --mode oneshot --thread off
|
||||
/acp spawn codex --thread here
|
||||
```
|
||||
|
||||
Key flags:
|
||||
|
||||
- `--mode persistent|oneshot`
|
||||
- `--thread auto|here|off`
|
||||
- `--cwd <absolute-path>`
|
||||
- `--label <name>`
|
||||
|
||||
See [Slash Commands](/tools/slash-commands).
|
||||
|
||||
## ACP controls
|
||||
|
||||
Available command family:
|
||||
|
||||
- `/acp spawn`
|
||||
- `/acp cancel`
|
||||
- `/acp steer`
|
||||
- `/acp close`
|
||||
- `/acp status`
|
||||
- `/acp set-mode`
|
||||
- `/acp set`
|
||||
- `/acp cwd`
|
||||
- `/acp permissions`
|
||||
- `/acp timeout`
|
||||
- `/acp model`
|
||||
- `/acp reset-options`
|
||||
- `/acp sessions`
|
||||
- `/acp doctor`
|
||||
- `/acp install`
|
||||
|
||||
`/acp status` shows the effective runtime options and, when available, both runtime-level and backend-level session identifiers.
|
||||
|
||||
Some controls depend on backend capabilities. If a backend does not support a control, OpenClaw returns a clear unsupported-control error.
|
||||
|
||||
## acpx harness support (current)
|
||||
|
||||
Current acpx built-in harness aliases:
|
||||
|
||||
- `pi`
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
|
||||
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
|
||||
|
||||
Direct acpx CLI usage can also target arbitrary adapters via `--agent <command>`, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path).
|
||||
|
||||
## Required config
|
||||
|
||||
Core ACP baseline:
|
||||
|
||||
```json5
|
||||
{
|
||||
acp: {
|
||||
enabled: true,
|
||||
dispatch: { enabled: true },
|
||||
backend: "acpx",
|
||||
defaultAgent: "codex",
|
||||
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
|
||||
maxConcurrentSessions: 8,
|
||||
stream: {
|
||||
coalesceIdleMs: 300,
|
||||
maxChunkChars: 1200,
|
||||
},
|
||||
runtime: {
|
||||
ttlMinutes: 120,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Thread binding config is channel-adapter specific. Example for Discord:
|
||||
|
||||
```json5
|
||||
{
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If thread-bound ACP spawn does not work, verify the adapter feature flag first:
|
||||
|
||||
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
|
||||
|
||||
See [Configuration Reference](/gateway/configuration-reference).
|
||||
|
||||
## Plugin setup for acpx backend
|
||||
|
||||
Install and enable plugin:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/acpx
|
||||
openclaw config set plugins.entries.acpx.enabled true
|
||||
```
|
||||
|
||||
Local workspace install during development:
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/acpx
|
||||
```
|
||||
|
||||
Then verify backend health:
|
||||
|
||||
```text
|
||||
/acp doctor
|
||||
```
|
||||
|
||||
### Pinned acpx install strategy (current behavior)
|
||||
|
||||
`@openclaw/acpx` now enforces a strict plugin-local pinning model:
|
||||
|
||||
1. The extension pins an exact acpx dependency in `extensions/acpx/package.json`.
|
||||
2. Runtime command is fixed to the plugin-local binary (`extensions/acpx/node_modules/.bin/acpx`), not global `PATH`.
|
||||
3. Plugin config does not expose `command` or `commandArgs`, so runtime command drift is blocked.
|
||||
4. Startup registers the ACP backend immediately as not-ready.
|
||||
5. A background ensure job verifies `acpx --version` against the pinned version.
|
||||
6. If missing/mismatched, it runs plugin-local install (`npm install --omit=dev --no-save acpx@<pinned>`) and re-verifies before healthy.
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenClaw startup stays non-blocking while acpx ensure runs.
|
||||
- If network/install fails, backend remains unavailable and `/acp doctor` reports an actionable fix.
|
||||
|
||||
See [Plugins](/tools/plugin).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Error: `ACP runtime backend is not configured`
|
||||
Install and enable the configured backend plugin, then run `/acp doctor`.
|
||||
|
||||
- Error: ACP dispatch disabled
|
||||
Enable `acp.dispatch.enabled=true`.
|
||||
|
||||
- Error: target agent not allowed
|
||||
Pass an allowed `agentId` or update `acp.allowedAgents`.
|
||||
|
||||
- Error: thread binding unavailable on this channel
|
||||
Use a channel adapter that supports thread bindings, or run ACP in non-thread mode.
|
||||
|
||||
- Error: missing ACP metadata for a bound session
|
||||
Recreate the session with `/acp spawn` (or `sessions_spawn` with `runtime:"acp"`) and rebind the thread.
|
||||
@@ -464,7 +464,7 @@ Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `thinking?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
|
||||
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
|
||||
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
|
||||
|
||||
Notes:
|
||||
@@ -474,6 +474,7 @@ Notes:
|
||||
- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
|
||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
|
||||
- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
|
||||
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
|
||||
- If `thread: true` and `mode` is omitted, mode defaults to `session`.
|
||||
|
||||
@@ -452,6 +452,29 @@ Notes:
|
||||
- `meta.preferOver` lists channel ids to skip auto-enable when both are configured.
|
||||
- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons.
|
||||
|
||||
### Channel onboarding hooks
|
||||
|
||||
Channel plugins can define optional onboarding hooks on `plugin.onboarding`:
|
||||
|
||||
- `configure(ctx)` is the baseline setup flow.
|
||||
- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states.
|
||||
- `configureWhenConfigured(ctx)` can override behavior only for already configured channels.
|
||||
|
||||
Hook precedence in the wizard:
|
||||
|
||||
1. `configureInteractive` (if present)
|
||||
2. `configureWhenConfigured` (only when channel status is already configured)
|
||||
3. fallback to `configure`
|
||||
|
||||
Context details:
|
||||
|
||||
- `configureInteractive` and `configureWhenConfigured` receive:
|
||||
- `configured` (`true` or `false`)
|
||||
- `label` (user-facing channel name used by prompts)
|
||||
- plus the shared config/runtime/prompter/options fields
|
||||
- Returning `"skip"` leaves selection and account tracking unchanged.
|
||||
- Returning `{ cfg, accountId? }` applies config updates and records account selection.
|
||||
|
||||
### Write a new messaging channel (step‑by‑step)
|
||||
|
||||
Use this when you want a **new chat surface** (a "messaging channel"), not a model provider.
|
||||
|
||||
@@ -80,6 +80,7 @@ Text + native (when enabled):
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/session ttl <duration|off>` (manage session-level settings, such as TTL)
|
||||
- `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session)
|
||||
- `/acp spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions` (inspect and control ACP runtime sessions)
|
||||
- `/agents` (list thread-bound agents for this session)
|
||||
- `/focus <target>` (Discord: bind this thread, or a new thread, to a session/subagent target)
|
||||
- `/unfocus` (Discord: remove the current thread binding)
|
||||
@@ -125,6 +126,7 @@ Notes:
|
||||
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
|
||||
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
|
||||
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
|
||||
- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents).
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
|
||||
@@ -51,6 +51,7 @@ These commands work on channels that support persistent thread bindings. See **T
|
||||
- `--model` and `--thinking` override defaults for that specific run.
|
||||
- Use `info`/`log` to inspect details and output after completion.
|
||||
- `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`.
|
||||
- For ACP harness sessions (Codex, Claude Code, Gemini CLI), use `sessions_spawn` with `runtime: "acp"` and see [ACP Agents](/tools/acp-agents).
|
||||
|
||||
Primary goals:
|
||||
|
||||
|
||||
19
extensions/acpx/index.ts
Normal file
19
extensions/acpx/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { createAcpxPluginConfigSchema } from "./src/config.js";
|
||||
import { createAcpxRuntimeService } from "./src/service.js";
|
||||
|
||||
const plugin = {
|
||||
id: "acpx",
|
||||
name: "ACPX Runtime",
|
||||
description: "ACP runtime backend powered by the acpx CLI.",
|
||||
configSchema: createAcpxPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerService(
|
||||
createAcpxRuntimeService({
|
||||
pluginConfig: api.pluginConfig,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
55
extensions/acpx/openclaw.plugin.json
Normal file
55
extensions/acpx/openclaw.plugin.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"id": "acpx",
|
||||
"name": "ACPX Runtime",
|
||||
"description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.",
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["approve-all", "approve-reads", "deny-all"]
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"type": "string",
|
||||
"enum": ["deny", "fail"]
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0.001
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"cwd": {
|
||||
"label": "Default Working Directory",
|
||||
"help": "Default cwd for ACP session operations when not set per session."
|
||||
},
|
||||
"permissionMode": {
|
||||
"label": "Permission Mode",
|
||||
"help": "Default acpx permission policy for runtime prompts."
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"label": "Non-Interactive Permission Policy",
|
||||
"help": "acpx policy when interactive permission prompts are unavailable."
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"label": "Prompt Timeout Seconds",
|
||||
"help": "Optional acpx timeout for each runtime turn.",
|
||||
"advanced": true
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"label": "Queue Owner TTL Seconds",
|
||||
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
|
||||
"advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
14
extensions/acpx/package.json
Normal file
14
extensions/acpx/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.2.25",
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "^0.1.13"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
209
extensions/acpx/skills/acp-router/SKILL.md
Normal file
209
extensions/acpx/skills/acp-router/SKILL.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: acp-router
|
||||
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow).
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
## Intent detection
|
||||
|
||||
Trigger this skill when the user asks OpenClaw to:
|
||||
|
||||
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
|
||||
- continue existing harness work
|
||||
- relay instructions to an external coding harness
|
||||
- keep an external harness conversation in a thread-like conversation
|
||||
|
||||
## Mode selection
|
||||
|
||||
Choose one of these paths:
|
||||
|
||||
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
|
||||
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
|
||||
|
||||
Use direct `acpx` when one of these is true:
|
||||
|
||||
- user explicitly asks for direct `acpx` driving
|
||||
- ACP runtime/plugin path is unavailable or unhealthy
|
||||
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
|
||||
|
||||
Do not use:
|
||||
|
||||
- `subagents` runtime for harness control
|
||||
- `/acp` command delegation as a requirement for the user
|
||||
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
Use these defaults when user names a harness directly:
|
||||
|
||||
- "pi" -> `agentId: "pi"`
|
||||
- "claude" or "claude code" -> `agentId: "claude"`
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
|
||||
These defaults match current acpx built-in aliases.
|
||||
|
||||
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
|
||||
|
||||
## OpenClaw ACP runtime path
|
||||
|
||||
Required behavior:
|
||||
|
||||
1. Use `sessions_spawn` with:
|
||||
- `runtime: "acp"`
|
||||
- `thread: true`
|
||||
- `mode: "session"` (unless user explicitly wants one-shot)
|
||||
2. Put requested work in `task` so the ACP session gets it immediately.
|
||||
3. Set `agentId` explicitly unless ACP default agent is known.
|
||||
4. Do not ask user to run slash commands or CLI when this path works directly.
|
||||
|
||||
Example:
|
||||
|
||||
User: "spawn a test codex session in thread and tell it to say hi"
|
||||
|
||||
Call:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Say hi.",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"thread": true,
|
||||
"mode": "session"
|
||||
}
|
||||
```
|
||||
|
||||
## Thread spawn recovery policy
|
||||
|
||||
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
|
||||
Required behavior when ACP backend is unavailable:
|
||||
|
||||
1. Do not immediately ask the user to pick an alternate path.
|
||||
2. First attempt automatic local repair:
|
||||
- ensure plugin-local pinned acpx is installed in `extensions/acpx`
|
||||
- verify `${ACPX_CMD} --version`
|
||||
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
|
||||
4. Retry ACP thread spawn once after repair.
|
||||
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
|
||||
|
||||
When offering fallback, keep ACP first:
|
||||
|
||||
- Option 1: retry ACP spawn after showing exact failing step
|
||||
- Option 2: direct acpx telephone-game flow
|
||||
|
||||
Do not default to subagent runtime for these requests.
|
||||
|
||||
## ACPX install and version policy (direct acpx path)
|
||||
|
||||
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension.
|
||||
|
||||
1. Prefer plugin-local binary, not global PATH:
|
||||
- `./extensions/acpx/node_modules/.bin/acpx`
|
||||
2. Resolve pinned version from extension dependency:
|
||||
- `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"`
|
||||
3. If binary is missing or version mismatched, install plugin-local pinned version:
|
||||
- `cd extensions/acpx && npm install --omit=dev --no-save acpx@<pinnedVersion>`
|
||||
4. Verify before use:
|
||||
- `./extensions/acpx/node_modules/.bin/acpx --version`
|
||||
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
|
||||
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
|
||||
|
||||
Set and reuse:
|
||||
|
||||
```bash
|
||||
ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx"
|
||||
```
|
||||
|
||||
## Direct acpx path ("telephone game")
|
||||
|
||||
Use this path to drive harness sessions without `/acp` or subagent runtime.
|
||||
|
||||
### Rules
|
||||
|
||||
1. Use `exec` commands that call `${ACPX_CMD}`.
|
||||
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
|
||||
3. Prefer `--format quiet` for clean assistant text to relay back to user.
|
||||
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
|
||||
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
|
||||
|
||||
### Session naming
|
||||
|
||||
Use a deterministic name, for example:
|
||||
|
||||
- `oc-<harness>-<conversationId>`
|
||||
|
||||
Where `conversationId` is thread id when available, otherwise channel/conversation id.
|
||||
|
||||
### Command templates
|
||||
|
||||
Persistent session (create if missing, then prompt):
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|
||||
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
|
||||
|
||||
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
One-shot:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
Cancel in-flight turn:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
Close session:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
### Harness aliases in acpx
|
||||
|
||||
- `pi`
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
Defaults are:
|
||||
|
||||
- `pi -> npx pi-acp`
|
||||
- `claude -> npx -y @zed-industries/claude-agent-acp`
|
||||
- `codex -> npx @zed-industries/codex-acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `gemini -> gemini`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
|
||||
### Failure handling
|
||||
|
||||
- `acpx: command not found`:
|
||||
- for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately
|
||||
- restart gateway after install and offer to run the restart automatically
|
||||
- then retry once
|
||||
- do not ask for install permission first unless policy explicitly requires it
|
||||
- do not install global `acpx` unless explicitly requested
|
||||
- adapter command missing (for example `claude-agent-acp` not found):
|
||||
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
|
||||
- then retry once before offering fallback
|
||||
- if user wants binary-based overrides, install exactly the configured adapter binary
|
||||
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
|
||||
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
|
||||
|
||||
### Output relay
|
||||
|
||||
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.
|
||||
53
extensions/acpx/src/config.test.ts
Normal file
53
extensions/acpx/src/config.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACPX_BUNDLED_BIN,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
it("resolves a strict plugin-local acpx command", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
cwd: "/tmp/workspace",
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
|
||||
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
|
||||
});
|
||||
|
||||
it("rejects command overrides", () => {
|
||||
expect(() =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
command: "acpx-custom",
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
).toThrow("unknown config key: command");
|
||||
});
|
||||
|
||||
it("rejects commandArgs overrides", () => {
|
||||
expect(() =>
|
||||
resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
commandArgs: ["--foo"],
|
||||
},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
}),
|
||||
).toThrow("unknown config key: commandArgs");
|
||||
});
|
||||
|
||||
it("schema rejects empty cwd", () => {
|
||||
const schema = createAcpxPluginConfigSchema();
|
||||
if (!schema.safeParse) {
|
||||
throw new Error("acpx config schema missing safeParse");
|
||||
}
|
||||
const parsed = schema.safeParse({ cwd: " " });
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
196
extensions/acpx/src/config.ts
Normal file
196
extensions/acpx/src/config.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
|
||||
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
||||
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
|
||||
|
||||
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
|
||||
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
|
||||
|
||||
export const ACPX_PINNED_VERSION = "0.1.13";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||
export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`;
|
||||
|
||||
export type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
};
|
||||
|
||||
export type ResolvedAcpxPluginConfig = {
|
||||
command: string;
|
||||
cwd: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
||||
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
|
||||
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
|
||||
|
||||
type ParseResult =
|
||||
| { ok: true; value: AcpxPluginConfig | undefined }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isPermissionMode(value: string): value is AcpxPermissionMode {
|
||||
return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
|
||||
}
|
||||
|
||||
function isNonInteractivePermissionPolicy(
|
||||
value: string,
|
||||
): value is AcpxNonInteractivePermissionPolicy {
|
||||
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
|
||||
}
|
||||
|
||||
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
||||
if (value === undefined) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return { ok: false, message: "expected config object" };
|
||||
}
|
||||
const allowedKeys = new Set([
|
||||
"cwd",
|
||||
"permissionMode",
|
||||
"nonInteractivePermissions",
|
||||
"timeoutSeconds",
|
||||
"queueOwnerTtlSeconds",
|
||||
]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return { ok: false, message: `unknown config key: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = value.cwd;
|
||||
if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
|
||||
return { ok: false, message: "cwd must be a non-empty string" };
|
||||
}
|
||||
|
||||
const permissionMode = value.permissionMode;
|
||||
if (
|
||||
permissionMode !== undefined &&
|
||||
(typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const nonInteractivePermissions = value.nonInteractivePermissions;
|
||||
if (
|
||||
nonInteractivePermissions !== undefined &&
|
||||
(typeof nonInteractivePermissions !== "string" ||
|
||||
!isNonInteractivePermissionPolicy(nonInteractivePermissions))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutSeconds = value.timeoutSeconds;
|
||||
if (
|
||||
timeoutSeconds !== undefined &&
|
||||
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
|
||||
) {
|
||||
return { ok: false, message: "timeoutSeconds must be a positive number" };
|
||||
}
|
||||
|
||||
const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
|
||||
if (
|
||||
queueOwnerTtlSeconds !== undefined &&
|
||||
(typeof queueOwnerTtlSeconds !== "number" ||
|
||||
!Number.isFinite(queueOwnerTtlSeconds) ||
|
||||
queueOwnerTtlSeconds < 0)
|
||||
) {
|
||||
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
|
||||
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
|
||||
nonInteractivePermissions:
|
||||
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
|
||||
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
|
||||
queueOwnerTtlSeconds:
|
||||
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
|
||||
return {
|
||||
safeParse(value: unknown):
|
||||
| { success: true; data?: unknown }
|
||||
| {
|
||||
success: false;
|
||||
error: { issues: Array<{ path: Array<string | number>; message: string }> };
|
||||
} {
|
||||
const parsed = parseAcpxPluginConfig(value);
|
||||
if (parsed.ok) {
|
||||
return { success: true, data: parsed.value };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
issues: [{ path: [], message: parsed.message }],
|
||||
},
|
||||
};
|
||||
},
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
cwd: { type: "string" },
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
enum: [...ACPX_PERMISSION_MODES],
|
||||
},
|
||||
nonInteractivePermissions: {
|
||||
type: "string",
|
||||
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
|
||||
},
|
||||
timeoutSeconds: { type: "number", minimum: 0.001 },
|
||||
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAcpxPluginConfig(params: {
|
||||
rawConfig: unknown;
|
||||
workspaceDir?: string;
|
||||
}): ResolvedAcpxPluginConfig {
|
||||
const parsed = parseAcpxPluginConfig(params.rawConfig);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.message);
|
||||
}
|
||||
const normalized = parsed.value ?? {};
|
||||
const fallbackCwd = params.workspaceDir?.trim() || process.cwd();
|
||||
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
|
||||
|
||||
return {
|
||||
command: ACPX_BUNDLED_BIN,
|
||||
cwd,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
||||
timeoutSeconds: normalized.timeoutSeconds,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
125
extensions/acpx/src/ensure.test.ts
Normal file
125
extensions/acpx/src/ensure.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js";
|
||||
|
||||
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
|
||||
resolveSpawnFailureMock: vi.fn(() => null),
|
||||
spawnAndCollectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-internals/process.js", () => ({
|
||||
resolveSpawnFailure: resolveSpawnFailureMock,
|
||||
spawnAndCollect: spawnAndCollectMock,
|
||||
}));
|
||||
|
||||
import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js";
|
||||
|
||||
describe("acpx ensure", () => {
|
||||
beforeEach(() => {
|
||||
resolveSpawnFailureMock.mockReset();
|
||||
resolveSpawnFailureMock.mockReturnValue(null);
|
||||
spawnAndCollectMock.mockReset();
|
||||
});
|
||||
|
||||
it("accepts the pinned acpx version", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await checkPinnedAcpxVersion({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
version: ACPX_PINNED_VERSION,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports version mismatch", async () => {
|
||||
spawnAndCollectMock.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await checkPinnedAcpxVersion({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
cwd: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
installedVersion: "0.0.9",
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
});
|
||||
});
|
||||
|
||||
it("installs and verifies pinned acpx when precheck fails", async () => {
|
||||
spawnAndCollectMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "added 1 package\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await ensurePinnedAcpx({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
pluginRoot: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
|
||||
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
|
||||
cwd: "/plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails with actionable error when npm install fails", async () => {
|
||||
spawnAndCollectMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "acpx 0.0.9\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
error: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "",
|
||||
stderr: "network down",
|
||||
code: 1,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensurePinnedAcpx({
|
||||
command: "/plugin/node_modules/.bin/acpx",
|
||||
pluginRoot: "/plugin",
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
}),
|
||||
).rejects.toThrow("failed to install plugin-local acpx");
|
||||
});
|
||||
});
|
||||
169
extensions/acpx/src/ensure.ts
Normal file
169
extensions/acpx/src/ensure.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk";
|
||||
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js";
|
||||
import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js";
|
||||
|
||||
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/;
|
||||
|
||||
export type AcpxVersionCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
version: string;
|
||||
expectedVersion: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed";
|
||||
message: string;
|
||||
expectedVersion: string;
|
||||
installCommand: string;
|
||||
installedVersion?: string;
|
||||
};
|
||||
|
||||
function extractVersion(stdout: string, stderr: string): string | null {
|
||||
const combined = `${stdout}\n${stderr}`;
|
||||
const match = combined.match(SEMVER_PATTERN);
|
||||
return match?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function checkPinnedAcpxVersion(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
expectedVersion?: string;
|
||||
}): Promise<AcpxVersionCheckResult> {
|
||||
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
|
||||
const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
|
||||
const result = await spawnAndCollect({
|
||||
command: params.command,
|
||||
args: ["--version"],
|
||||
cwd,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing-command",
|
||||
message: `acpx command not found at ${params.command}`,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: "execution-failed",
|
||||
message: result.error.message,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
return {
|
||||
ok: false,
|
||||
reason: "execution-failed",
|
||||
message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
const installedVersion = extractVersion(result.stdout, result.stderr);
|
||||
if (!installedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "missing-version",
|
||||
message: "acpx --version output did not include a parseable version",
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
if (installedVersion !== expectedVersion) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "version-mismatch",
|
||||
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
|
||||
expectedVersion,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
installedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
version: installedVersion,
|
||||
expectedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
let pendingEnsure: Promise<void> | null = null;
|
||||
|
||||
export async function ensurePinnedAcpx(params: {
|
||||
command: string;
|
||||
logger?: PluginLogger;
|
||||
pluginRoot?: string;
|
||||
expectedVersion?: string;
|
||||
}): Promise<void> {
|
||||
if (pendingEnsure) {
|
||||
return await pendingEnsure;
|
||||
}
|
||||
|
||||
pendingEnsure = (async () => {
|
||||
const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
|
||||
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
|
||||
|
||||
const precheck = await checkPinnedAcpxVersion({
|
||||
command: params.command,
|
||||
cwd: pluginRoot,
|
||||
expectedVersion,
|
||||
});
|
||||
if (precheck.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
params.logger?.warn(
|
||||
`acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`,
|
||||
);
|
||||
|
||||
const install = await spawnAndCollect({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`],
|
||||
cwd: pluginRoot,
|
||||
});
|
||||
|
||||
if (install.error) {
|
||||
const spawnFailure = resolveSpawnFailure(install.error, pluginRoot);
|
||||
if (spawnFailure === "missing-command") {
|
||||
throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
|
||||
}
|
||||
throw new Error(`failed to install plugin-local acpx: ${install.error.message}`);
|
||||
}
|
||||
|
||||
if ((install.code ?? 0) !== 0) {
|
||||
const stderr = install.stderr.trim();
|
||||
const stdout = install.stdout.trim();
|
||||
const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`;
|
||||
throw new Error(`failed to install plugin-local acpx: ${detail}`);
|
||||
}
|
||||
|
||||
const postcheck = await checkPinnedAcpxVersion({
|
||||
command: params.command,
|
||||
cwd: pluginRoot,
|
||||
expectedVersion,
|
||||
});
|
||||
|
||||
if (!postcheck.ok) {
|
||||
throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
|
||||
}
|
||||
|
||||
params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`);
|
||||
})();
|
||||
|
||||
try {
|
||||
await pendingEnsure;
|
||||
} finally {
|
||||
pendingEnsure = null;
|
||||
}
|
||||
}
|
||||
140
extensions/acpx/src/runtime-internals/events.ts
Normal file
140
extensions/acpx/src/runtime-internals/events.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
asOptionalBoolean,
|
||||
asOptionalString,
|
||||
asString,
|
||||
asTrimmedString,
|
||||
type AcpxErrorEvent,
|
||||
type AcpxJsonObject,
|
||||
isRecord,
|
||||
} from "./shared.js";
|
||||
|
||||
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (asTrimmedString(value.type) !== "error") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
message: asTrimmedString(value.message) || "acpx reported an error",
|
||||
code: asOptionalString(value.code),
|
||||
retryable: asOptionalBoolean(value.retryable),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseJsonLines(value: string): AcpxJsonObject[] {
|
||||
const events: AcpxJsonObject[] = [];
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (isRecord(parsed)) {
|
||||
events.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines; callers handle missing typed events via exit code.
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return {
|
||||
type: "status",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(parsed.type);
|
||||
switch (type) {
|
||||
case "text": {
|
||||
const content = asString(parsed.content);
|
||||
if (content == null || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: content,
|
||||
stream: "output",
|
||||
};
|
||||
}
|
||||
case "thought": {
|
||||
const content = asString(parsed.content);
|
||||
if (content == null || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: content,
|
||||
stream: "thought",
|
||||
};
|
||||
}
|
||||
case "tool_call": {
|
||||
const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
|
||||
const status = asTrimmedString(parsed.status);
|
||||
return {
|
||||
type: "tool_call",
|
||||
text: status ? `${title} (${status})` : title,
|
||||
};
|
||||
}
|
||||
case "client_operation": {
|
||||
const method = asTrimmedString(parsed.method) || "operation";
|
||||
const status = asTrimmedString(parsed.status);
|
||||
const summary = asTrimmedString(parsed.summary);
|
||||
const text = [method, status, summary].filter(Boolean).join(" ");
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text };
|
||||
}
|
||||
case "plan": {
|
||||
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
|
||||
const content = asTrimmedString(first?.content);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text: `plan: ${content}` };
|
||||
}
|
||||
case "update": {
|
||||
const update = asTrimmedString(parsed.update);
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
return { type: "status", text: update };
|
||||
}
|
||||
case "done": {
|
||||
return {
|
||||
type: "done",
|
||||
stopReason: asOptionalString(parsed.stopReason),
|
||||
};
|
||||
}
|
||||
case "error": {
|
||||
const message = asTrimmedString(parsed.message) || "acpx runtime error";
|
||||
return {
|
||||
type: "error",
|
||||
message,
|
||||
code: asOptionalString(parsed.code),
|
||||
retryable: asOptionalBoolean(parsed.retryable),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
137
extensions/acpx/src/runtime-internals/process.ts
Normal file
137
extensions/acpx/src/runtime-internals/process.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type SpawnExit = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type ResolvedSpawnCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell?: boolean;
|
||||
};
|
||||
|
||||
function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand {
|
||||
if (process.platform !== "win32") {
|
||||
return { command: params.command, args: params.args };
|
||||
}
|
||||
|
||||
const extension = path.extname(params.command).toLowerCase();
|
||||
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [params.command, ...params.args],
|
||||
};
|
||||
}
|
||||
|
||||
if (extension === ".cmd" || extension === ".bat") {
|
||||
return {
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
shell: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
};
|
||||
}
|
||||
|
||||
export function spawnWithResolvedCommand(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}): ChildProcessWithoutNullStreams {
|
||||
const resolved = resolveSpawnCommand({
|
||||
command: params.command,
|
||||
args: params.args,
|
||||
});
|
||||
|
||||
return spawn(resolved.command, resolved.args, {
|
||||
cwd: params.cwd,
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: resolved.shell,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
|
||||
return await new Promise<SpawnExit>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: SpawnExit) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.once("error", (err) => {
|
||||
finish({ code: null, signal: null, error: err });
|
||||
});
|
||||
|
||||
child.once("close", (code, signal) => {
|
||||
finish({ code, signal, error: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function spawnAndCollect(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
const child = spawnWithResolvedCommand(params);
|
||||
child.stdin.end();
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
const exit = await waitForExit(child);
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
code: exit.code,
|
||||
error: exit.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSpawnFailure(
|
||||
err: unknown,
|
||||
cwd: string,
|
||||
): "missing-command" | "missing-cwd" | null {
|
||||
if (!err || typeof err !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
|
||||
}
|
||||
|
||||
function directoryExists(cwd: string): boolean {
|
||||
if (!cwd) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return existsSync(cwd);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
56
extensions/acpx/src/runtime-internals/shared.ts
Normal file
56
extensions/acpx/src/runtime-internals/shared.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
|
||||
export type AcpxHandleState = {
|
||||
name: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
acpxRecordId?: string;
|
||||
backendSessionId?: string;
|
||||
agentSessionId?: string;
|
||||
};
|
||||
|
||||
export type AcpxJsonObject = Record<string, unknown>;
|
||||
|
||||
export type AcpxErrorEvent = {
|
||||
message: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asTrimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function asOptionalString(value: unknown): string | undefined {
|
||||
const text = asTrimmedString(value);
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function asOptionalBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
|
||||
const match = sessionKey.match(/^agent:([^:]+):/i);
|
||||
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
|
||||
return candidate || fallbackAgent;
|
||||
}
|
||||
|
||||
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
|
||||
if (mode === "approve-all") {
|
||||
return ["--approve-all"];
|
||||
}
|
||||
if (mode === "deny-all") {
|
||||
return ["--deny-all"];
|
||||
}
|
||||
return ["--approve-reads"];
|
||||
}
|
||||
619
extensions/acpx/src/runtime.test.ts
Normal file
619
extensions/acpx/src/runtime.test.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import fs from "node:fs";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
|
||||
import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
|
||||
const NOOP_LOGGER = {
|
||||
info: (_message: string) => {},
|
||||
warn: (_message: string) => {},
|
||||
error: (_message: string) => {},
|
||||
debug: (_message: string) => {},
|
||||
};
|
||||
|
||||
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const logPath = process.env.MOCK_ACPX_LOG;
|
||||
const writeLog = (entry) => {
|
||||
if (!logPath) return;
|
||||
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
||||
};
|
||||
|
||||
if (args.includes("--version")) {
|
||||
process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args.includes("--help")) {
|
||||
process.stdout.write("mock-acpx help\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commandIndex = args.findIndex(
|
||||
(arg) =>
|
||||
arg === "prompt" ||
|
||||
arg === "cancel" ||
|
||||
arg === "sessions" ||
|
||||
arg === "set-mode" ||
|
||||
arg === "set" ||
|
||||
arg === "status",
|
||||
);
|
||||
const command = commandIndex >= 0 ? args[commandIndex] : "";
|
||||
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
|
||||
|
||||
const readFlag = (flag) => {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx < 0) return "";
|
||||
return String(args[idx + 1] || "");
|
||||
};
|
||||
|
||||
const sessionFromOption = readFlag("--session");
|
||||
const ensureName = readFlag("--name");
|
||||
const closeName = command === "sessions" && args[commandIndex + 1] === "close" ? String(args[commandIndex + 2] || "") : "";
|
||||
const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : "";
|
||||
const setKey = command === "set" ? String(args[commandIndex + 1] || "") : "";
|
||||
const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
||||
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "session_ensured",
|
||||
acpxRecordId: "rec-" + ensureName,
|
||||
acpxSessionId: "sid-" + ensureName,
|
||||
agentSessionId: "inner-" + ensureName,
|
||||
name: ensureName,
|
||||
created: true,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "cancel") {
|
||||
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
|
||||
process.stdout.write(JSON.stringify({
|
||||
acpxSessionId: "sid-" + sessionFromOption,
|
||||
cancelled: true,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "set-mode") {
|
||||
writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "mode_set",
|
||||
acpxSessionId: "sid-" + sessionFromOption,
|
||||
mode: setModeValue,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "set") {
|
||||
writeLog({
|
||||
kind: "set",
|
||||
agent,
|
||||
args,
|
||||
sessionName: sessionFromOption,
|
||||
key: setKey,
|
||||
value: setValue,
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "config_set",
|
||||
acpxSessionId: "sid-" + sessionFromOption,
|
||||
key: setKey,
|
||||
value: setValue,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
|
||||
process.stdout.write(JSON.stringify({
|
||||
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
|
||||
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
|
||||
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
|
||||
status: sessionFromOption ? "alive" : "no-session",
|
||||
pid: 4242,
|
||||
uptime: 120,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "close") {
|
||||
writeLog({ kind: "close", agent, args, sessionName: closeName });
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: "session_closed",
|
||||
acpxRecordId: "rec-" + closeName,
|
||||
acpxSessionId: "sid-" + closeName,
|
||||
name: closeName,
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "prompt") {
|
||||
const stdinText = fs.readFileSync(0, "utf8");
|
||||
writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText });
|
||||
const acpxSessionId = "sid-" + sessionFromOption;
|
||||
|
||||
if (stdinText.includes("trigger-error")) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 0,
|
||||
stream: "prompt",
|
||||
type: "error",
|
||||
code: "RUNTIME",
|
||||
message: "mock failure",
|
||||
}) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (stdinText.includes("split-spacing")) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 0,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: "alpha",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 1,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: " beta",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 2,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: " gamma",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 3,
|
||||
stream: "prompt",
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 0,
|
||||
stream: "prompt",
|
||||
type: "thought",
|
||||
content: "thinking",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 1,
|
||||
stream: "prompt",
|
||||
type: "tool_call",
|
||||
title: "run-tests",
|
||||
status: "in_progress",
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 2,
|
||||
stream: "prompt",
|
||||
type: "text",
|
||||
content: "echo:" + stdinText.trim(),
|
||||
}) + "\n");
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId,
|
||||
requestId: "req-1",
|
||||
seq: 3,
|
||||
stream: "prompt",
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
}) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
writeLog({ kind: "unknown", args });
|
||||
process.stdout.write(JSON.stringify({
|
||||
eventVersion: 1,
|
||||
acpxSessionId: "unknown",
|
||||
seq: 0,
|
||||
stream: "control",
|
||||
type: "error",
|
||||
code: "USAGE",
|
||||
message: "unknown command",
|
||||
}) + "\n");
|
||||
process.exit(2);
|
||||
`;
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createMockRuntime(params?: {
|
||||
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
|
||||
queueOwnerTtlSeconds?: number;
|
||||
}): Promise<{
|
||||
runtime: AcpxRuntime;
|
||||
logPath: string;
|
||||
config: ResolvedAcpxPluginConfig;
|
||||
}> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-"));
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, "mock-acpx.cjs");
|
||||
const logPath = path.join(dir, "calls.log");
|
||||
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
process.env.MOCK_ACPX_LOG = logPath;
|
||||
|
||||
const config: ResolvedAcpxPluginConfig = {
|
||||
command: scriptPath,
|
||||
cwd: dir,
|
||||
permissionMode: params?.permissionMode ?? "approve-all",
|
||||
nonInteractivePermissions: "fail",
|
||||
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
|
||||
};
|
||||
|
||||
return {
|
||||
runtime: new AcpxRuntime(config, {
|
||||
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
|
||||
logger: NOOP_LOGGER,
|
||||
}),
|
||||
logPath,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLogEntries(logPath: string): Promise<Array<Record<string, unknown>>> {
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return [];
|
||||
}
|
||||
const raw = await readFile(logPath, "utf8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 10,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("AcpxRuntime", () => {
|
||||
it("passes the shared ACP adapter contract suite", async () => {
|
||||
const fixture = await createMockRuntime();
|
||||
await runAcpRuntimeAdapterContract({
|
||||
createRuntime: async () => fixture.runtime,
|
||||
agentId: "codex",
|
||||
successPrompt: "contract-pass",
|
||||
errorPrompt: "trigger-error",
|
||||
assertSuccessEvents: (events) => {
|
||||
expect(events.some((event) => event.type === "done")).toBe(true);
|
||||
},
|
||||
assertErrorOutcome: ({ events, thrown }) => {
|
||||
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
const logs = await readLogEntries(fixture.logPath);
|
||||
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
|
||||
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
|
||||
});
|
||||
|
||||
it("ensures sessions and streams prompt events", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 });
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:123",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
|
||||
expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
|
||||
expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
|
||||
expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
|
||||
expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "hello world",
|
||||
mode: "prompt",
|
||||
requestId: "req-test",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "text_delta",
|
||||
text: "thinking",
|
||||
stream: "thought",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "tool_call",
|
||||
text: "run-tests (in_progress)",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "text_delta",
|
||||
text: "echo:hello world",
|
||||
stream: "output",
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: "done",
|
||||
stopReason: "end_turn",
|
||||
});
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
const ensure = logs.find((entry) => entry.kind === "ensure");
|
||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
||||
expect(ensure).toBeDefined();
|
||||
expect(prompt).toBeDefined();
|
||||
expect(Array.isArray(prompt?.args)).toBe(true);
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
expect(promptArgs).toContain("--ttl");
|
||||
expect(promptArgs).toContain("180");
|
||||
expect(promptArgs).toContain("--approve-all");
|
||||
});
|
||||
|
||||
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:ttl-default",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
for await (const _event of runtime.runTurn({
|
||||
handle,
|
||||
text: "ttl-default",
|
||||
mode: "prompt",
|
||||
requestId: "req-ttl-default",
|
||||
})) {
|
||||
// drain
|
||||
}
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
const prompt = logs.find((entry) => entry.kind === "prompt");
|
||||
expect(prompt).toBeDefined();
|
||||
const promptArgs = (prompt?.args as string[]) ?? [];
|
||||
const ttlFlagIndex = promptArgs.indexOf("--ttl");
|
||||
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
|
||||
});
|
||||
|
||||
it("preserves leading spaces across streamed text deltas", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:space",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const textDeltas: string[] = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "split-spacing",
|
||||
mode: "prompt",
|
||||
requestId: "req-space",
|
||||
})) {
|
||||
if (event.type === "text_delta" && event.stream === "output") {
|
||||
textDeltas.push(event.text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
|
||||
expect(textDeltas.join("")).toBe("alpha beta gamma");
|
||||
});
|
||||
|
||||
it("maps acpx error events into ACP runtime error events", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:456",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "trigger-error",
|
||||
mode: "prompt",
|
||||
requestId: "req-err",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "error",
|
||||
message: "mock failure",
|
||||
code: "RUNTIME",
|
||||
retryable: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports cancel and close using encoded runtime handle state", async () => {
|
||||
const { runtime, logPath, config } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:claude:acp:789",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
expect(decoded?.name).toBe("agent:claude:acp:789");
|
||||
|
||||
const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
|
||||
|
||||
await secondRuntime.cancel({ handle, reason: "test" });
|
||||
await secondRuntime.close({ handle, reason: "test" });
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
const cancel = logs.find((entry) => entry.kind === "cancel");
|
||||
const close = logs.find((entry) => entry.kind === "close");
|
||||
expect(cancel?.sessionName).toBe("agent:claude:acp:789");
|
||||
expect(close?.sessionName).toBe("agent:claude:acp:789");
|
||||
});
|
||||
|
||||
it("exposes control capabilities and runs set-mode/set/status commands", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:controls",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
const capabilities = runtime.getCapabilities();
|
||||
expect(capabilities.controls).toContain("session/set_mode");
|
||||
expect(capabilities.controls).toContain("session/set_config_option");
|
||||
expect(capabilities.controls).toContain("session/status");
|
||||
|
||||
await runtime.setMode({
|
||||
handle,
|
||||
mode: "plan",
|
||||
});
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key: "model",
|
||||
value: "openai-codex/gpt-5.3-codex",
|
||||
});
|
||||
const status = await runtime.getStatus({ handle });
|
||||
const ensuredSessionName = "agent:codex:acp:controls";
|
||||
|
||||
expect(status.summary).toContain("status=alive");
|
||||
expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
|
||||
expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
|
||||
expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
|
||||
expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
|
||||
expect(status.details?.status).toBe("alive");
|
||||
expect(status.details?.pid).toBe(4242);
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
|
||||
expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
|
||||
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
|
||||
});
|
||||
|
||||
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
|
||||
const { runtime, logPath } = await createMockRuntime();
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:aborted",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "should-not-run",
|
||||
mode: "prompt",
|
||||
requestId: "req-aborted",
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const logs = await readLogEntries(logPath);
|
||||
expect(events).toEqual([]);
|
||||
expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
|
||||
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:missing-cwd",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
cwd: missingCwd,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_SESSION_INIT_FAILED",
|
||||
message: expect.stringContaining("working directory does not exist"),
|
||||
});
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
});
|
||||
|
||||
it("marks runtime unhealthy when command is missing", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks runtime healthy when command is available", async () => {
|
||||
const { runtime } = await createMockRuntime();
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns doctor report for missing command", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
const report = await runtime.doctor();
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
expect(report.installCommand).toContain("acpx");
|
||||
});
|
||||
});
|
||||
578
extensions/acpx/src/runtime.ts
Normal file
578
extensions/acpx/src/runtime.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import type {
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntime,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeErrorCode,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { AcpRuntimeError } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
ACPX_LOCAL_INSTALL_COMMAND,
|
||||
ACPX_PINNED_VERSION,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
import { checkPinnedAcpxVersion } from "./ensure.js";
|
||||
import {
|
||||
parseJsonLines,
|
||||
parsePromptEventLine,
|
||||
toAcpxErrorEvent,
|
||||
} from "./runtime-internals/events.js";
|
||||
import {
|
||||
resolveSpawnFailure,
|
||||
spawnAndCollect,
|
||||
spawnWithResolvedCommand,
|
||||
waitForExit,
|
||||
} from "./runtime-internals/process.js";
|
||||
import {
|
||||
asOptionalString,
|
||||
asTrimmedString,
|
||||
buildPermissionArgs,
|
||||
deriveAgentFromSessionKey,
|
||||
isRecord,
|
||||
type AcpxHandleState,
|
||||
type AcpxJsonObject,
|
||||
} from "./runtime-internals/shared.js";
|
||||
|
||||
export const ACPX_BACKEND_ID = "acpx";
|
||||
|
||||
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
|
||||
const DEFAULT_AGENT_FALLBACK = "codex";
|
||||
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
|
||||
controls: ["session/set_mode", "session/set_config_option", "session/status"],
|
||||
};
|
||||
|
||||
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
|
||||
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||||
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
||||
}
|
||||
|
||||
export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
|
||||
const trimmed = runtimeSessionName.trim();
|
||||
if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const name = asTrimmedString(parsed.name);
|
||||
const agent = asTrimmedString(parsed.agent);
|
||||
const cwd = asTrimmedString(parsed.cwd);
|
||||
const mode = asTrimmedString(parsed.mode);
|
||||
const acpxRecordId = asOptionalString(parsed.acpxRecordId);
|
||||
const backendSessionId = asOptionalString(parsed.backendSessionId);
|
||||
const agentSessionId = asOptionalString(parsed.agentSessionId);
|
||||
if (!name || !agent || !cwd) {
|
||||
return null;
|
||||
}
|
||||
if (mode !== "persistent" && mode !== "oneshot") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
agent,
|
||||
cwd,
|
||||
mode,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(backendSessionId ? { backendSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AcpxRuntime implements AcpRuntime {
|
||||
private healthy = false;
|
||||
private readonly logger?: PluginLogger;
|
||||
private readonly queueOwnerTtlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: ResolvedAcpxPluginConfig,
|
||||
opts?: {
|
||||
logger?: PluginLogger;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
},
|
||||
) {
|
||||
this.logger = opts?.logger;
|
||||
const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds;
|
||||
this.queueOwnerTtlSeconds =
|
||||
typeof requestedQueueOwnerTtlSeconds === "number" &&
|
||||
Number.isFinite(requestedQueueOwnerTtlSeconds) &&
|
||||
requestedQueueOwnerTtlSeconds >= 0
|
||||
? requestedQueueOwnerTtlSeconds
|
||||
: this.config.queueOwnerTtlSeconds;
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.healthy;
|
||||
}
|
||||
|
||||
async probeAvailability(): Promise<void> {
|
||||
const versionCheck = await checkPinnedAcpxVersion({
|
||||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
this.healthy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAndCollect({
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
});
|
||||
this.healthy = result.error == null && (result.code ?? 0) === 0;
|
||||
} catch {
|
||||
this.healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||
const sessionName = asTrimmedString(input.sessionKey);
|
||||
if (!sessionName) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
const agent = asTrimmedString(input.agent);
|
||||
if (!agent) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
|
||||
}
|
||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||
const mode = input.mode;
|
||||
|
||||
const events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd,
|
||||
command: [agent, "sessions", "ensure", "--name", sessionName],
|
||||
}),
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
const ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
|
||||
const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
|
||||
const backendSessionId = ensuredEvent
|
||||
? asOptionalString(ensuredEvent.acpxSessionId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: ACPX_BACKEND_ID,
|
||||
runtimeSessionName: encodeAcpxRuntimeHandleState({
|
||||
name: sessionName,
|
||||
agent,
|
||||
cwd,
|
||||
mode,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(backendSessionId ? { backendSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
}),
|
||||
cwd,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(backendSessionId ? { backendSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const args = this.buildPromptArgs({
|
||||
agent: state.agent,
|
||||
sessionName: state.name,
|
||||
cwd: state.cwd,
|
||||
});
|
||||
|
||||
const cancelOnAbort = async () => {
|
||||
await this.cancel({
|
||||
handle: input.handle,
|
||||
reason: "abort-signal",
|
||||
}).catch((err) => {
|
||||
this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`);
|
||||
});
|
||||
};
|
||||
const onAbort = () => {
|
||||
void cancelOnAbort();
|
||||
};
|
||||
|
||||
if (input.signal?.aborted) {
|
||||
await cancelOnAbort();
|
||||
return;
|
||||
}
|
||||
if (input.signal) {
|
||||
input.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
const child = spawnWithResolvedCommand({
|
||||
command: this.config.command,
|
||||
args,
|
||||
cwd: state.cwd,
|
||||
});
|
||||
child.stdin.on("error", () => {
|
||||
// Ignore EPIPE when the child exits before stdin flush completes.
|
||||
});
|
||||
|
||||
child.stdin.end(input.text);
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
|
||||
let sawDone = false;
|
||||
let sawError = false;
|
||||
const lines = createInterface({ input: child.stdout });
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
const parsed = parsePromptEventLine(line);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.type === "done") {
|
||||
sawDone = true;
|
||||
}
|
||||
if (parsed.type === "error") {
|
||||
sawError = true;
|
||||
}
|
||||
yield parsed;
|
||||
}
|
||||
|
||||
const exit = await waitForExit(child);
|
||||
if (exit.error) {
|
||||
const spawnFailure = resolveSpawnFailure(exit.error, state.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
`acpx command not found: ${this.config.command}`,
|
||||
{ cause: exit.error },
|
||||
);
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_TURN_FAILED",
|
||||
`ACP runtime working directory does not exist: ${state.cwd}`,
|
||||
{ cause: exit.error },
|
||||
);
|
||||
}
|
||||
throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error });
|
||||
}
|
||||
|
||||
if ((exit.code ?? 0) !== 0 && !sawError) {
|
||||
yield {
|
||||
type: "error",
|
||||
message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sawDone && !sawError) {
|
||||
yield { type: "done" };
|
||||
}
|
||||
} finally {
|
||||
lines.close();
|
||||
if (input.signal) {
|
||||
input.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities(): AcpRuntimeCapabilities {
|
||||
return ACPX_CAPABILITIES;
|
||||
}
|
||||
|
||||
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const events = await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "status", "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
|
||||
if (!detail) {
|
||||
return {
|
||||
summary: "acpx status unavailable",
|
||||
};
|
||||
}
|
||||
const status = asTrimmedString(detail.status) || "unknown";
|
||||
const acpxRecordId = asOptionalString(detail.acpxRecordId);
|
||||
const acpxSessionId = asOptionalString(detail.acpxSessionId);
|
||||
const agentSessionId = asOptionalString(detail.agentSessionId);
|
||||
const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
|
||||
const summary = [
|
||||
`status=${status}`,
|
||||
acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
|
||||
acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
|
||||
pid != null ? `pid=${pid}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return {
|
||||
summary,
|
||||
...(acpxRecordId ? { acpxRecordId } : {}),
|
||||
...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}),
|
||||
...(agentSessionId ? { agentSessionId } : {}),
|
||||
details: detail,
|
||||
};
|
||||
}
|
||||
|
||||
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const mode = asTrimmedString(input.mode);
|
||||
if (!mode) {
|
||||
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
|
||||
}
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "set-mode", mode, "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
async setConfigOption(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const key = asTrimmedString(input.key);
|
||||
const value = asTrimmedString(input.value);
|
||||
if (!key || !value) {
|
||||
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
|
||||
}
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "set", key, value, "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
async doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
const versionCheck = await checkPinnedAcpxVersion({
|
||||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
this.healthy = false;
|
||||
const details = [
|
||||
`expected=${versionCheck.expectedVersion}`,
|
||||
versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null,
|
||||
].filter((detail): detail is string => Boolean(detail));
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: versionCheck.message,
|
||||
installCommand: versionCheck.installCommand,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAndCollect({
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
});
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: `acpx command not found: ${this.config.command}`,
|
||||
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
|
||||
};
|
||||
}
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: result.error.message,
|
||||
details: [String(result.error)],
|
||||
};
|
||||
}
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
this.healthy = true;
|
||||
return {
|
||||
ok: true,
|
||||
message: `acpx command available (${this.config.command}, version ${versionCheck.version})`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "cancel", "--session", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
}
|
||||
|
||||
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
await this.runControlCommand({
|
||||
args: this.buildControlArgs({
|
||||
cwd: state.cwd,
|
||||
command: [state.agent, "sessions", "close", state.name],
|
||||
}),
|
||||
cwd: state.cwd,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
if (decoded) {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
const legacyName = asTrimmedString(handle.runtimeSessionName);
|
||||
if (!legacyName) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
"Invalid acpx runtime handle: runtimeSessionName is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: legacyName,
|
||||
agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK),
|
||||
cwd: this.config.cwd,
|
||||
mode: "persistent",
|
||||
};
|
||||
}
|
||||
|
||||
private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
|
||||
return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
|
||||
}
|
||||
|
||||
private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
|
||||
const args = [
|
||||
"--format",
|
||||
"json",
|
||||
"--json-strict",
|
||||
"--cwd",
|
||||
params.cwd,
|
||||
...buildPermissionArgs(this.config.permissionMode),
|
||||
"--non-interactive-permissions",
|
||||
this.config.nonInteractivePermissions,
|
||||
];
|
||||
if (this.config.timeoutSeconds) {
|
||||
args.push("--timeout", String(this.config.timeoutSeconds));
|
||||
}
|
||||
args.push("--ttl", String(this.queueOwnerTtlSeconds));
|
||||
args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
|
||||
return args;
|
||||
}
|
||||
|
||||
private async runControlCommand(params: {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
fallbackCode: AcpRuntimeErrorCode;
|
||||
ignoreNoSession?: boolean;
|
||||
}): Promise<AcpxJsonObject[]> {
|
||||
const result = await spawnAndCollect({
|
||||
command: this.config.command,
|
||||
args: params.args,
|
||||
cwd: params.cwd,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, params.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_UNAVAILABLE",
|
||||
`acpx command not found: ${this.config.command}`,
|
||||
{ cause: result.error },
|
||||
);
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
throw new AcpRuntimeError(
|
||||
params.fallbackCode,
|
||||
`ACP runtime working directory does not exist: ${params.cwd}`,
|
||||
{ cause: result.error },
|
||||
);
|
||||
}
|
||||
throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error });
|
||||
}
|
||||
|
||||
const events = parseJsonLines(result.stdout);
|
||||
const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
|
||||
if (errorEvent) {
|
||||
if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") {
|
||||
return events;
|
||||
}
|
||||
throw new AcpRuntimeError(
|
||||
params.fallbackCode,
|
||||
errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message,
|
||||
);
|
||||
}
|
||||
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
throw new AcpRuntimeError(
|
||||
params.fallbackCode,
|
||||
result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
}
|
||||
173
extensions/acpx/src/service.test.ts
Normal file
173
extensions/acpx/src/service.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
|
||||
import {
|
||||
__testing,
|
||||
getAcpRuntimeBackend,
|
||||
requireAcpRuntimeBackend,
|
||||
} from "../../../src/acp/runtime/registry.js";
|
||||
import { ACPX_BUNDLED_BIN } from "./config.js";
|
||||
import { createAcpxRuntimeService } from "./service.js";
|
||||
|
||||
const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({
|
||||
ensurePinnedAcpxSpy: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./ensure.js", () => ({
|
||||
ensurePinnedAcpx: ensurePinnedAcpxSpy,
|
||||
}));
|
||||
|
||||
type RuntimeStub = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
};
|
||||
|
||||
function createRuntimeStub(healthy: boolean): {
|
||||
runtime: RuntimeStub;
|
||||
probeAvailabilitySpy: ReturnType<typeof vi.fn>;
|
||||
isHealthySpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const probeAvailabilitySpy = vi.fn(async () => {});
|
||||
const isHealthySpy = vi.fn(() => healthy);
|
||||
return {
|
||||
runtime: {
|
||||
ensureSession: vi.fn(async (input) => ({
|
||||
sessionKey: input.sessionKey,
|
||||
backend: "acpx",
|
||||
runtimeSessionName: input.sessionKey,
|
||||
})),
|
||||
runTurn: vi.fn(async function* () {
|
||||
yield { type: "done" as const };
|
||||
}),
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
async probeAvailability() {
|
||||
await probeAvailabilitySpy();
|
||||
},
|
||||
isHealthy() {
|
||||
return isHealthySpy();
|
||||
},
|
||||
},
|
||||
probeAvailabilitySpy,
|
||||
isHealthySpy,
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceContext(
|
||||
overrides: Partial<OpenClawPluginServiceContext> = {},
|
||||
): OpenClawPluginServiceContext {
|
||||
return {
|
||||
config: {},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
stateDir: "/tmp/state",
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAcpxRuntimeService", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetAcpRuntimeBackendsForTests();
|
||||
ensurePinnedAcpxSpy.mockReset();
|
||||
ensurePinnedAcpxSpy.mockImplementation(async () => {});
|
||||
});
|
||||
|
||||
it("registers and unregisters the acpx backend", async () => {
|
||||
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce();
|
||||
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
await service.stop?.(context);
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks backend unavailable when runtime health check fails", async () => {
|
||||
const { runtime } = createRuntimeStub(false);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
|
||||
expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
|
||||
try {
|
||||
requireAcpRuntimeBackend("acpx");
|
||||
throw new Error("expected ACP backend lookup to fail");
|
||||
} catch (error) {
|
||||
expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
|
||||
it("passes queue-owner TTL from plugin config", async () => {
|
||||
const { runtime } = createRuntimeStub(true);
|
||||
const runtimeFactory = vi.fn(() => runtime);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory,
|
||||
pluginConfig: {
|
||||
queueOwnerTtlSeconds: 0.25,
|
||||
},
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueOwnerTtlSeconds: 0.25,
|
||||
pluginConfig: expect.objectContaining({
|
||||
command: ACPX_BUNDLED_BIN,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a short default queue-owner TTL", async () => {
|
||||
const { runtime } = createRuntimeStub(true);
|
||||
const runtimeFactory = vi.fn(() => runtime);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
await service.start(context);
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not block startup while acpx ensure runs", async () => {
|
||||
const { runtime } = createRuntimeStub(true);
|
||||
ensurePinnedAcpxSpy.mockImplementation(() => new Promise<void>(() => {}));
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime,
|
||||
});
|
||||
const context = createServiceContext();
|
||||
|
||||
const startResult = await Promise.race([
|
||||
Promise.resolve(service.start(context)).then(() => "started"),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("timed_out"), 100)),
|
||||
]);
|
||||
|
||||
expect(startResult).toBe("started");
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
});
|
||||
});
|
||||
102
extensions/acpx/src/service.ts
Normal file
102
extensions/acpx/src/service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
ACPX_PINNED_VERSION,
|
||||
resolveAcpxPluginConfig,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
import { ensurePinnedAcpx } from "./ensure.js";
|
||||
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
};
|
||||
|
||||
type AcpxRuntimeFactoryParams = {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
queueOwnerTtlSeconds: number;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type CreateAcpxRuntimeServiceParams = {
|
||||
pluginConfig?: unknown;
|
||||
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
|
||||
};
|
||||
|
||||
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
return new AcpxRuntime(params.pluginConfig, {
|
||||
logger: params.logger,
|
||||
queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAcpxRuntimeService(
|
||||
params: CreateAcpxRuntimeServiceParams = {},
|
||||
): OpenClawPluginService {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let lifecycleRevision = 0;
|
||||
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
|
||||
runtime = runtimeFactory({
|
||||
pluginConfig,
|
||||
queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
registerAcpRuntimeBackend({
|
||||
id: ACPX_BACKEND_ID,
|
||||
runtime,
|
||||
healthy: () => runtime?.isHealthy() ?? false,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`,
|
||||
);
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
void (async () => {
|
||||
try {
|
||||
await ensurePinnedAcpx({
|
||||
command: pluginConfig.command,
|
||||
logger: ctx.logger,
|
||||
expectedVersion: ACPX_PINNED_VERSION,
|
||||
});
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
await runtime?.probeAvailability();
|
||||
if (runtime?.isHealthy()) {
|
||||
ctx.logger.info("acpx runtime backend ready");
|
||||
} else {
|
||||
ctx.logger.warn("acpx runtime backend probe failed after local install");
|
||||
}
|
||||
} catch (err) {
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
lifecycleRevision += 1;
|
||||
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
runtime = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/open-prose",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.2.25",
|
||||
"version": "2026.2.26",
|
||||
"private": true,
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user