diff --git a/openclaw-plugin/.gitignore b/openclaw-plugin/.gitignore new file mode 100644 index 0000000..c9c9210 --- /dev/null +++ b/openclaw-plugin/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +*.tsbuildinfo + diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md new file mode 100644 index 0000000..266168d --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,54 @@ +# @world2agent/openclaw-plugin + +Native OpenClaw plugin for running World2Agent sensors and dispatching their signals into embedded OpenClaw agent turns. + +The default path is in-process: enabled sensors are imported directly inside the plugin process and each signal is sent to `api.runtime.agent.runEmbeddedAgent(...)`. `isolated: true` is opt-in and reuses the Hermes bridge runner/supervisor patterns for subprocess execution plus plugin-local HTTP ingest. + +## Install + +1. Set the required OpenClaw agent config first: + + ```yaml + agents: + defaults: + contextInjection: continuation-skip + ``` + +2. Install dependencies and build this package: + + ```bash + cd world2agent-plugins/openclaw-plugin + pnpm install + pnpm build + ``` + +3. Add the plugin package to your OpenClaw plugin search/install path and enable `@world2agent/openclaw-plugin`. + +4. Use the registered CLI: + + ```bash + openclaw world2agent sensor list + openclaw world2agent sensor add @world2agent/sensor-hackernews --config-file ./hackernews.json + ``` + +## Scope + +- Reads and writes the W2A sensor manifest at `~/.world2agent/sensors.json` by default. +- Runs sensors in-process unless a sensor entry sets `isolated: true`. +- Reuses the Hermes runner/supervisor patterns instead of inventing a second isolation protocol. +- Uses a stable per-sensor embedded-agent session id: `w2a:`. +- Requires plugin config `ingestUrl` only when `isolated: true` sensors are used. + +## ContextInjection Prerequisite + +This plugin refuses to start unless `agents.defaults.contextInjection` is exactly `"continuation-skip"`. + +That check also runs before `openclaw world2agent sensor add`. There is no warning mode, no fallback mode, and no override flag. The design requires a hard failure because OpenClaw's default `"always"` setting would re-inject bootstrap on every sensor signal and silently turn high-frequency sensors into a token sink. + +## Relation To `hermes-sensor-bridge` + +`hermes-sensor-bridge` solved the same World2Agent runtime problem for Hermes with webhook subscriptions plus supervised subprocesses. This package keeps the same manifest shape and reuses the runner/supervisor mechanics for `isolated: true`, but the primary OpenClaw path is simpler: native plugin registration plus `runEmbeddedAgent(...)`. + +## Known M0 Spike + +`api.runtime.agent.runEmbeddedAgent(...)` from a third-party external plugin remains a live-install verification point. This package guards it defensively and throws a clear error if the runtime helper is absent, but a real OpenClaw install still has to confirm the end-to-end external-plugin path. diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 0000000..550e803 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,66 @@ +{ + "id": "world2agent", + "name": "World2Agent", + "description": "Run World2Agent sensors inside OpenClaw and dispatch signals into embedded agent turns.", + "skills": [ + "./skills/world2agent-manage" + ], + "commandAliases": [ + "world2agent" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "sensorsManifestPath": { + "type": "string", + "description": "Absolute or relative path to the World2Agent sensor manifest." + }, + "stateDir": { + "type": "string", + "description": "Directory for per-sensor FileSensorStore state." + }, + "sessionDir": { + "type": "string", + "description": "Directory for plugin-managed embedded-agent session files." + }, + "workspaceDir": { + "type": "string", + "description": "Workspace directory passed to runEmbeddedAgent when the OpenClaw runtime helper is unavailable." + }, + "ingestUrl": { + "type": "string", + "description": "Absolute URL for the plugin's /w2a/ingest route when isolated runners are enabled." + }, + "defaultAgentId": { + "type": "string", + "default": "world2agent", + "description": "Dedicated OpenClaw agent id whose skills allowlist should receive W2A skills." + }, + "provider": { + "type": "string", + "description": "Provider passed to runEmbeddedAgent (e.g. 'openai-codex' for OAuth, 'openai' for API key). When unset OpenClaw resolves from agent/global defaults." + }, + "model": { + "type": "string", + "description": "Model id passed to runEmbeddedAgent (e.g. 'gpt-5.4'). When unset OpenClaw resolves from agent/global defaults." + }, + "requestTimeoutMs": { + "type": "integer", + "minimum": 1, + "default": 120000, + "description": "Timeout passed to runEmbeddedAgent and isolated ingest POSTs." + }, + "ingestHmacSecretFile": { + "type": "string", + "description": "Path to the HMAC secret used by isolated runner ingest requests." + }, + "ingestDedupTtlMs": { + "type": "integer", + "minimum": 1000, + "default": 3600000, + "description": "How long X-Request-ID dedup entries stay in memory." + } + } + } +} diff --git a/openclaw-plugin/package-lock.json b/openclaw-plugin/package-lock.json new file mode 100644 index 0000000..a52177d --- /dev/null +++ b/openclaw-plugin/package-lock.json @@ -0,0 +1,1322 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "license": "Apache-2.0", + "dependencies": { + "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@world2agent/sdk": { + "version": "0.1.0-alpha.1", + "resolved": "file:../../world2agent-typescript-sdk", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "zod": "^3.25.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json new file mode 100644 index 0000000..aa785e8 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,69 @@ +{ + "name": "@world2agent/openclaw-plugin", + "version": "0.0.0-dev", + "description": "World2Agent native plugin for OpenClaw", + "license": "Apache-2.0", + "author": "MachinePulse Pte. Ltd.", + "homepage": "https://github.com/machinepulse-ai/world2agent", + "repository": { + "type": "git", + "url": "git+https://github.com/machinepulse-ai/world2agent-plugins.git", + "directory": "openclaw-plugin" + }, + "bugs": { + "url": "https://github.com/machinepulse-ai/world2agent-plugins/issues" + }, + "keywords": [ + "world2agent", + "w2a", + "openclaw", + "plugin", + "sensor" + ], + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc --build", + "clean": "rm -rf dist *.tsbuildinfo", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "pnpm run clean && pnpm run build" + }, + "dependencies": { + "@world2agent/sdk": "file:../../world2agent-typescript-sdk", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^4.1.5" + }, + "files": [ + "dist", + "openclaw.plugin.json", + "skills", + "README.md" + ], + "openclaw": { + "extensions": [ + "./src/index.ts" + ], + "runtimeExtensions": [ + "./dist/index.js" + ] + }, + "publishConfig": { + "access": "public" + } +} + diff --git a/openclaw-plugin/skills/world2agent-manage/SKILL.md b/openclaw-plugin/skills/world2agent-manage/SKILL.md new file mode 100644 index 0000000..69ca8a7 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/SKILL.md @@ -0,0 +1,79 @@ +--- +name: world2agent-manage +description: Manage World2Agent sensors for OpenClaw. Use when the user asks to install, list, remove, or inspect W2A sensors, or wants an outside-world source such as Hacker News, GitHub, RSS, calendars, or market feeds. +user-invocable: false +--- + +# World2Agent Sensor Management + +You manage the user's World2Agent sensors on this OpenClaw machine. + +All mutations go through the `openclaw world2agent` CLI. The shell scripts in +`scripts/` are thin wrappers around those commands. + +## Prerequisite + +Before adding sensors, OpenClaw must be configured with: + +```yaml +agents: + defaults: + contextInjection: continuation-skip +``` + +If that field is not set exactly, `openclaw world2agent sensor add` will fail on +purpose. Do not try to work around it. + +## List sensors + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/list.sh" +``` + +## Install a sensor + +1. Confirm the npm package name with the user. +2. Inspect the sensor package's `SETUP.md` to determine the config fields it needs. +3. Write a temporary JSON file containing the sensor config object only. +4. Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/add.sh" --config-file +``` + +Optional flags: + +- `--sensor-id ` if the user wants a non-default instance id. +- `--isolated` if the sensor should run out-of-process. + +Never invent credentials or secrets. Ask the user when the config requires them. + +## Remove a sensor + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/remove.sh" +``` + +Pass `--purge` only if the user explicitly wants the generated OpenClaw skill +directory removed too. + +## Reload sensors + +Run: + +```bash +bash "$W2A_PLUGIN_HOME/skills/world2agent-manage/scripts/reload.sh" +``` + +## Output style + +After each action, summarize: + +- which sensor ids were affected +- whether the reload succeeded +- any warnings or errors returned by the CLI + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/add.sh b/openclaw-plugin/skills/world2agent-manage/scripts/add.sh new file mode 100755 index 0000000..7a38c22 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/add.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor add "$@" + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/list.sh b/openclaw-plugin/skills/world2agent-manage/scripts/list.sh new file mode 100755 index 0000000..bb42a74 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/list.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor list "$@" + diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh b/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh new file mode 100755 index 0000000..6ed2b42 --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/reload.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent reload "$@" diff --git a/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh b/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh new file mode 100755 index 0000000..a85fb9d --- /dev/null +++ b/openclaw-plugin/skills/world2agent-manage/scripts/remove.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec openclaw world2agent sensor remove "$@" + diff --git a/openclaw-plugin/src/cli.ts b/openclaw-plugin/src/cli.ts new file mode 100644 index 0000000..32705d6 --- /dev/null +++ b/openclaw-plugin/src/cli.ts @@ -0,0 +1,220 @@ +import { join } from "node:path"; +import { packageToSkillId } from "@world2agent/sdk"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + upsertDedicatedAgentSkillAllowlist, +} from "./config.js"; +import { ensurePackageInstalled, loadConfigFile, maybeUninstallPackage, runCommand, writeGeneratedSkill } from "./install.js"; +import { + defaultSensorId, + readManifest, + removePath, + removeSensorEntry, + upsertSensorEntry, + writeManifest, +} from "./manifest.js"; +import type { + OpenClawConfig, + OpenClawPluginApi, +} from "./openclaw/plugin-sdk/types.js"; +import type { RequiredWorld2AgentPluginConfig, SensorEntry, World2AgentPaths } from "./types.js"; + +export interface World2AgentCliServices { + api: OpenClawPluginApi; + paths: World2AgentPaths; + pluginConfig: RequiredWorld2AgentPluginConfig; +} + +export function registerWorld2AgentCli(services: World2AgentCliServices): void { + services.api.registerCli?.( + ({ program }) => { + const root = program.command("world2agent").description("Manage World2Agent sensors"); + const sensor = root.command("sensor").description("Manage sensor instances"); + + sensor + .command("list") + .description("List configured sensors") + .action(async () => { + printJson(await runListCommand(services)); + }); + + sensor + .command("add ") + .description("Install and configure a sensor") + .option("--sensor-id ", "Override the default sensor id") + .option("--config-file ", "Path to the sensor config JSON file") + .option("--isolated", "Run this sensor out-of-process") + .action(async (pkg: string, options: Record) => { + printJson(await runAddCommand(services, pkg, options)); + }); + + sensor + .command("remove ") + .description("Remove a configured sensor") + .option("--purge", "Remove the generated skill directory and best-effort uninstall the package") + .action(async (sensorId: string, options: Record) => { + printJson(await runRemoveCommand(services, sensorId, options)); + }); + + root + .command("reload") + .description("Ask the running gateway plugin instance to reload sensors") + .action(async () => { + printJson(await runReloadCommand()); + }); + }, + { + descriptors: [ + { + name: "world2agent", + description: "Manage World2Agent sensors", + hasSubcommands: true, + }, + ], + }, + ); +} + +async function runListCommand( + services: World2AgentCliServices, +): Promise { + const config = await loadEffectiveOpenClawConfig(services.api); + const manifest = await readManifest(services.paths); + return { + ok: true, + contextInjection: config.agents?.defaults?.contextInjection ?? null, + dedicated_agent_id: services.pluginConfig.defaultAgentId, + sensors: manifest.sensors, + }; +} + +async function runAddCommand( + services: World2AgentCliServices, + pkg: string, + options: Record, +): Promise { + const config = await loadEffectiveOpenClawConfig(services.api); + assertContextInjectionCompatible(config); + + const installed = await ensurePackageInstalled(pkg); + const sensorId = optionString(options, "sensorId") ?? defaultSensorId(pkg); + const configFile = optionString(options, "configFile"); + const isolated = optionBoolean(options, "isolated"); + const skillId = packageToSkillId(pkg); + const sensorConfig = await loadConfigFile(configFile, installed); + await writeGeneratedSkill(services.paths, pkg, installed); + + const manifest = await readManifest(services.paths); + const entry: SensorEntry = { + sensor_id: sensorId, + pkg, + skill_id: skillId, + enabled: true, + isolated, + config: sensorConfig, + }; + await writeManifest(services.paths, upsertSensorEntry(manifest, entry)); + + const allowlist = await maybePersistAllowlist( + services.api, + config, + services.pluginConfig.defaultAgentId, + skillId, + ); + const reload = await runReloadCommand(); + + return { + ok: true, + sensor_id: sensorId, + skill_id: skillId, + isolated, + allowlist, + reload, + }; +} + +async function runRemoveCommand( + services: World2AgentCliServices, + sensorId: string, + options: Record, +): Promise { + const manifest = await readManifest(services.paths); + const { manifest: nextManifest, removed } = removeSensorEntry(manifest, sensorId); + if (!removed) { + throw new Error(`Sensor not found: ${sensorId}`); + } + + await writeManifest(services.paths, nextManifest); + + const purge = optionBoolean(options, "purge"); + if (purge) { + await removePath(join(services.paths.openclawSkillsDir, removed.skill_id)); + const stillUsesPackage = nextManifest.sensors.some((entry) => entry.pkg === removed.pkg); + if (!stillUsesPackage) { + await maybeUninstallPackage(removed.pkg); + } + } + + return { + ok: true, + removed, + purge, + reload: await runReloadCommand(), + }; +} + +async function runReloadCommand(): Promise { + try { + const { stdout } = await runCommand("openclaw", [ + "gateway", + "call", + "world2agent.reload", + "--json", + ]); + + try { + return JSON.parse(stdout); + } catch { + return { + ok: true, + raw: stdout.trim(), + }; + } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +async function maybePersistAllowlist( + api: OpenClawPluginApi, + config: OpenClawConfig, + agentId: string, + skillId: string, +): Promise { + const result = upsertDedicatedAgentSkillAllowlist(config, agentId, skillId); + if (result.changed && typeof api.runtime?.config?.writeConfigFile === "function") { + await api.runtime.config.writeConfigFile(result.nextConfig); + } + return { + changed: result.changed, + warning: result.warning, + }; +} + +function printJson(value: unknown): void { + process.stdout.write(JSON.stringify(value, null, 2) + "\n"); +} + +function optionString(options: Record, key: string): string | undefined { + const value = options[key]; + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function optionBoolean(options: Record, key: string): boolean { + return options[key] === true; +} + diff --git a/openclaw-plugin/src/config.ts b/openclaw-plugin/src/config.ts new file mode 100644 index 0000000..d22a810 --- /dev/null +++ b/openclaw-plugin/src/config.ts @@ -0,0 +1,133 @@ +import type { + OpenClawAgentConfig, + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; +import type { RequiredWorld2AgentPluginConfig } from "./types.js"; + +export const REQUIRED_CONTEXT_INJECTION = "continuation-skip"; + +export async function loadEffectiveOpenClawConfig( + api: OpenClawPluginApi, +): Promise { + if (typeof api.runtime?.config?.loadConfig === "function") { + return api.runtime.config.loadConfig(); + } + return api.config ?? {}; +} + +export function assertContextInjectionCompatible(config: OpenClawConfig): void { + const got = config.agents?.defaults?.contextInjection; + if (got === REQUIRED_CONTEXT_INJECTION) return; + + throw new Error( + "OpenClaw config field `agents.defaults.contextInjection` must be set to " + + `"${REQUIRED_CONTEXT_INJECTION}" for @world2agent/openclaw-plugin. ` + + `Current value: ${JSON.stringify(got)}. Update that exact field and retry.`, + ); +} + +export function normalizePluginConfig( + value: unknown, +): RequiredWorld2AgentPluginConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as OpenClawPluginConfig) + : {}; + + return { + sensorsManifestPath: asOptionalString(raw.sensorsManifestPath), + stateDir: asOptionalString(raw.stateDir), + sessionDir: asOptionalString(raw.sessionDir), + workspaceDir: asOptionalString(raw.workspaceDir), + ingestUrl: asOptionalString(raw.ingestUrl), + defaultAgentId: asOptionalString(raw.defaultAgentId) ?? "world2agent", + provider: asOptionalString((raw as Record).provider), + model: asOptionalString((raw as Record).model), + requestTimeoutMs: asPositiveInteger(raw.requestTimeoutMs) ?? 120_000, + ingestHmacSecretFile: asOptionalString(raw.ingestHmacSecretFile), + ingestDedupTtlMs: asPositiveInteger(raw.ingestDedupTtlMs) ?? 3_600_000, + }; +} + +export function hasDedicatedAgentSkillsAllowlist( + config: OpenClawConfig, + agentId: string, +): boolean { + const agent = findDedicatedAgent(config, agentId); + return Array.isArray(agent?.skills); +} + +export function findDedicatedAgent( + config: OpenClawConfig, + agentId: string, +): OpenClawAgentConfig | undefined { + return config.agents?.list?.find( + (entry) => entry.id === agentId || entry.name === agentId, + ); +} + +export function upsertDedicatedAgentSkillAllowlist( + config: OpenClawConfig, + agentId: string, + skillId: string, +): { + changed: boolean; + nextConfig: OpenClawConfig; + warning: string | null; +} { + const currentAgent = findDedicatedAgent(config, agentId); + if (!currentAgent) { + return { + changed: false, + nextConfig: config, + warning: + `Dedicated agent ${JSON.stringify(agentId)} was not found in agents.list; ` + + "installed skill will rely on prompt-prefix fallback until that agent exists.", + }; + } + + const currentSkills = Array.isArray(currentAgent.skills) + ? [...currentAgent.skills] + : []; + if (currentSkills.includes(skillId)) { + return { + changed: false, + nextConfig: config, + warning: null, + }; + } + + currentSkills.push(skillId); + currentSkills.sort(); + + const nextConfig: OpenClawConfig = structuredClone(config); + const nextAgent = findDedicatedAgent(nextConfig, agentId); + if (!nextAgent) { + return { + changed: false, + nextConfig: config, + warning: + `Dedicated agent ${JSON.stringify(agentId)} disappeared while cloning config; ` + + "installed skill will rely on prompt-prefix fallback.", + }; + } + nextAgent.skills = currentSkills; + + return { + changed: true, + nextConfig, + warning: null, + }; +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function asPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 + ? value + : undefined; +} diff --git a/openclaw-plugin/src/dispatch.ts b/openclaw-plugin/src/dispatch.ts new file mode 100644 index 0000000..7ebb112 --- /dev/null +++ b/openclaw-plugin/src/dispatch.ts @@ -0,0 +1,263 @@ +import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join } from "node:path"; +import { hasDedicatedAgentSkillsAllowlist } from "./config.js"; +import { renderSignalPrompt } from "./prompt.js"; +import type { OpenClawConfig } from "./openclaw/plugin-sdk/types.js"; +import type { + Dispatcher, + DispatcherDispatchInput, + EmbeddedDispatcherOptions, + HttpDispatcherOptions, + HttpIngestEnvelope, +} from "./types.js"; + +const RUN_EMBEDDED_AGENT_ERROR = + "M0 spike unverified: api.runtime.agent.runEmbeddedAgent not found — verify against a live OpenClaw install"; + +export function assertEmbeddedAgentRuntime(options: EmbeddedDispatcherOptions): void { + if (typeof options.api.runtime?.agent?.runEmbeddedAgent !== "function") { + throw new Error(RUN_EMBEDDED_AGENT_ERROR); + } +} + +export class EmbeddedDispatcher implements Dispatcher { + private readonly options: EmbeddedDispatcherOptions; + private readonly queues = new Map>(); + + constructor(options: EmbeddedDispatcherOptions) { + assertEmbeddedAgentRuntime(options); + this.options = options; + } + + async dispatch(input: DispatcherDispatchInput): Promise { + // OpenClaw enforces SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; + // colons aren't allowed in sessionId. sessionKey is the colon-namespaced lane. + const sessionId = input.sessionId ?? `w2a-${sanitizeSessionId(input.sensorId)}`; + const previous = this.queues.get(sessionId) ?? Promise.resolve(); + + const next = previous + .catch(() => undefined) + .then(() => this.dispatchNow(input, sessionId)); + + this.queues.set(sessionId, next); + try { + return await next; + } finally { + if (this.queues.get(sessionId) === next) { + this.queues.delete(sessionId); + } + } + } + + private async dispatchNow( + input: DispatcherDispatchInput, + sessionId: string, + ): Promise { + const prompt = renderSignalPrompt(input.signal, { + skillId: input.skillId, + useSkillPrefix: !hasDedicatedAgentSkillsAllowlist( + this.options.openclawConfigRef.current, + this.options.pluginConfig.defaultAgentId, + ), + }); + + const config = this.options.openclawConfigRef.current; + const runtimeAgent = this.options.api.runtime!.agent!; + const agentId = this.options.pluginConfig.defaultAgentId ?? "main"; + const sessionKey = `w2a:${input.sensorId}`; + const openclawHome = this.options.paths.openclawHome; + const workspaceDir = + this.options.pluginConfig.workspaceDir ?? + tryCall(() => runtimeAgent.resolveAgentWorkspaceDir?.(config, agentId)) ?? + join(openclawHome, "workspace"); + const agentDir = + tryCall(() => runtimeAgent.resolveAgentDir?.(config, agentId)) ?? + join(openclawHome, "agents", agentId); + const sessionFile = join(agentDir, "sessions", `${sessionId}.jsonl`); + const timeoutMs = + this.options.pluginConfig.requestTimeoutMs ?? + tryCall(() => runtimeAgent.resolveAgentTimeoutMs?.(config)) ?? + 120_000; + + // OpenClaw's runEmbeddedAgent silently defaults to "openai/gpt-5.4" when + // provider/model are absent — it does NOT read agents.defaults.model.primary. + // Resolve the effective default ourselves so signal-driven runs follow the + // operator's configured model. + const { provider, model } = resolveProviderAndModel( + config, + this.options.pluginConfig, + ); + + return runtimeAgent.runEmbeddedAgent!({ + sessionId, + sessionKey, + agentId, + runId: randomUUID(), + sessionFile, + workspaceDir, + agentDir, + config, + prompt, + timeoutMs, + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + } as Parameters>[0]); + } +} + +export class CliDispatcher implements Dispatcher { + async dispatch(_input: DispatcherDispatchInput): Promise { + // TODO: Keep this as an escape hatch only; do not make it load-bearing. + throw new Error("CliDispatcher is not implemented in M4 skeleton"); + } +} + +export class HttpDispatcher { + private readonly embeddedDispatcher: Dispatcher; + private readonly hmacSecret: string; + private readonly dedup = new RequestDeduper(); + private readonly dedupTtlMs: number; + + constructor(options: HttpDispatcherOptions) { + this.embeddedDispatcher = options.embeddedDispatcher; + this.hmacSecret = options.hmacSecret; + this.dedupTtlMs = options.dedupTtlMs; + } + + createRoute() { + return { + path: "/w2a/ingest", + auth: "plugin" as const, + handler: async (req: IncomingMessage, res: ServerResponse) => { + await this.handle(req, res); + }, + }; + } + + async handle(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== "POST") { + writeJson(res, 405, { ok: false, error: "method not allowed" }); + return; + } + + const body = await readBody(req); + if (!this.verifyHmac(body, req.headers["x-webhook-signature"])) { + writeJson(res, 401, { ok: false, error: "invalid signature" }); + return; + } + + const requestId = req.headers["x-request-id"]; + if (typeof requestId !== "string" || requestId.trim() === "") { + writeJson(res, 400, { ok: false, error: "missing X-Request-ID" }); + return; + } + if (this.dedup.seen(requestId, this.dedupTtlMs)) { + writeJson(res, 202, { ok: true, deduped: true }); + return; + } + + let payload: HttpIngestEnvelope; + try { + payload = JSON.parse(body) as HttpIngestEnvelope; + } catch (error) { + writeJson(res, 400, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + await this.embeddedDispatcher.dispatch({ + sensorId: payload.sensor_id, + skillId: payload.skill_id, + signal: payload.signal, + }); + + writeJson(res, 202, { ok: true }); + } + + private verifyHmac(body: string, signatureHeader: string | string[] | undefined): boolean { + if (typeof signatureHeader !== "string") return false; + const expected = Buffer.from( + createHmac("sha256", this.hmacSecret).update(body).digest("hex"), + "hex", + ); + const got = Buffer.from(signatureHeader, "hex"); + return expected.length === got.length && timingSafeEqual(expected, got); + } +} + +class RequestDeduper { + private readonly seenAt = new Map(); + + seen(id: string, ttlMs: number): boolean { + const now = Date.now(); + this.prune(now, ttlMs); + if (this.seenAt.has(id)) { + return true; + } + this.seenAt.set(id, now); + if (this.seenAt.size > 1_024) { + const oldest = this.seenAt.keys().next().value; + if (oldest) this.seenAt.delete(oldest); + } + return false; + } + + private prune(now: number, ttlMs: number): void { + for (const [id, seenAt] of this.seenAt) { + if (now - seenAt > ttlMs) { + this.seenAt.delete(id); + } + } + } +} + +function sanitizeSessionId(value: string): string { + // OpenClaw SAFE_SESSION_ID_RE: /^[a-z0-9][a-z0-9._-]{0,127}$/i. Map invalid + // chars to "-" (allowed) rather than "_" (also allowed but mixed-style). + return value.replace(/[^A-Za-z0-9._-]/g, "-"); +} + +function tryCall(fn: () => T): T | undefined { + try { + return fn(); + } catch { + return undefined; + } +} + +function resolveProviderAndModel( + config: OpenClawConfig, + pluginConfig: { provider?: string; model?: string }, +): { provider?: string; model?: string } { + if (pluginConfig.provider && pluginConfig.model) { + return { provider: pluginConfig.provider, model: pluginConfig.model }; + } + const primary = config.agents?.defaults?.model?.primary; + if (typeof primary === "string") { + const slash = primary.indexOf("/"); + if (slash > 0) { + return { + provider: pluginConfig.provider ?? primary.slice(0, slash), + model: pluginConfig.model ?? primary.slice(slash + 1), + }; + } + } + return { provider: pluginConfig.provider, model: pluginConfig.model }; +} + +async function readBody(req: IncomingMessage): Promise { + let raw = ""; + for await (const chunk of req) { + raw += chunk.toString(); + } + return raw; +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body, null, 2)); +} diff --git a/openclaw-plugin/src/index.ts b/openclaw-plugin/src/index.ts new file mode 100644 index 0000000..3ae4db4 --- /dev/null +++ b/openclaw-plugin/src/index.ts @@ -0,0 +1,7 @@ +import { createWorld2AgentPlugin } from "./plugin.js"; + +export { definePluginEntry } from "./openclaw/plugin-sdk/plugin-entry.js"; +export type * from "./openclaw/plugin-sdk/types.js"; +export { createWorld2AgentPlugin } from "./plugin.js"; + +export default createWorld2AgentPlugin(); diff --git a/openclaw-plugin/src/install.ts b/openclaw-plugin/src/install.ts new file mode 100644 index 0000000..2a007bf --- /dev/null +++ b/openclaw-plugin/src/install.ts @@ -0,0 +1,223 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { packageToSkillId } from "@world2agent/sdk"; +import { pathExists, removePath } from "./manifest.js"; +import type { World2AgentPaths } from "./types.js"; + +export interface InstalledPackageInfo { + packageJsonPath: string; + packageRoot: string; + packageJson: Record; +} + +export function pluginPackageRoot(): string { + return fileURLToPath(new URL("../", import.meta.url)); +} + +export async function resolveInstalledPackage( + pkg: string, +): Promise { + const require = createRequire(import.meta.url); + try { + const entryPath = require.resolve(pkg, { + paths: [pluginPackageRoot()], + }); + const packageJsonPath = await findNearestPackageJson(dirname(entryPath)); + const raw = JSON.parse(await readFile(packageJsonPath, "utf8")) as Record; + return { + packageJsonPath, + packageRoot: dirname(packageJsonPath), + packageJson: raw, + }; + } catch { + return null; + } +} + +export async function ensurePackageInstalled( + pkg: string, +): Promise { + const existing = await resolveInstalledPackage(pkg); + if (existing) return existing; + + const localRepo = await findLocalSensorRepo(pkg); + if (localRepo) { + await linkLocalPackage(pkg, localRepo); + const linked = await resolveInstalledPackage(pkg); + if (linked) return linked; + } + + await runCommand("npm", ["install", "--no-save", pkg], { + cwd: pluginPackageRoot(), + }); + const installed = await resolveInstalledPackage(pkg); + if (!installed) { + throw new Error(`Failed to resolve installed package ${pkg}`); + } + return installed; +} + +export async function maybeUninstallPackage( + pkg: string, +): Promise { + try { + await runCommand("npm", ["uninstall", "--no-save", pkg], { + cwd: pluginPackageRoot(), + }); + } catch { + // best effort + } +} + +export async function writeGeneratedSkill( + paths: World2AgentPaths, + pkg: string, + installed: InstalledPackageInfo, +): Promise { + const skillId = packageToSkillId(pkg); + const sourceType = String( + (installed.packageJson.w2a as Record | undefined)?.source_type ?? pkg, + ); + const signals = ( + (installed.packageJson.w2a as Record | undefined)?.signals as + | string[] + | undefined + )?.join(", "); + + const skillDir = join(paths.openclawSkillsDir, skillId); + await mkdir(skillDir, { recursive: true }); + const skillMd = [ + "---", + `name: ${skillId}`, + `description: Handle World2Agent signals from ${pkg}.`, + "user-invocable: false", + "---", + "", + `# ${skillId}`, + "", + `Handle W2A signals from \`${pkg}\` (source type: \`${sourceType}\`).`, + "", + "## Inputs", + "- The prompt body contains markdown context plus a fenced JSON copy of the full `signal` object.", + signals ? `- Common signal types: ${signals}` : "- Inspect `signal.event.type` for the exact event kind.", + "", + "## Behavior", + "- Parse the JSON when you need structured fields.", + "- If the signal is irrelevant or obviously low-value, skip silently.", + "- If it is actionable, reply briefly with the key fact, why it matters, and any obvious next step.", + "", + "## Notes", + "- This skill was generated by @world2agent/openclaw-plugin because the sensor package does not ship a richer OpenClaw-specific handler yet.", + "- If the dedicated World2Agent agent does not expose a skills allowlist, the plugin falls back to `Use skill: ` prompt routing.", + "", + ].join("\n"); + await writeFile(join(skillDir, "SKILL.md"), skillMd, "utf8"); + return skillId; +} + +export async function loadConfigFile( + configFile: string | undefined, + installed: InstalledPackageInfo, +): Promise> { + if (!configFile) { + const setupPath = String( + (installed.packageJson.w2a as Record | undefined)?.setup ?? "SETUP.md", + ); + throw new Error( + `Interactive setup is not implemented; use --config-file . Sensor guidance: ${join( + installed.packageRoot, + setupPath, + )}`, + ); + } + + const raw = JSON.parse(await readFile(configFile, "utf8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Config file must contain a JSON object: ${configFile}`); + } + return raw as Record; +} + +export async function runCommand( + command: string, + args: string[], + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolvePromise({ stdout, stderr }); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with code ${code}: ${ + stderr.trim() || stdout.trim() + }`, + ), + ); + }); + }); +} + +async function findLocalSensorRepo(pkg: string): Promise { + if (!pkg.startsWith("@world2agent/sensor-")) return null; + + const slug = pkg.split("/").pop()?.replace(/^sensor-/, ""); + if (!slug) return null; + + const candidate = resolve(pluginPackageRoot(), "..", "..", "world2agent-sensors", slug); + return (await pathExists(join(candidate, "package.json"))) ? candidate : null; +} + +async function linkLocalPackage(pkg: string, sourceDir: string): Promise { + const scope = pkg.split("/")[0]; + const name = pkg.split("/")[1]; + if (!scope || !name) { + throw new Error(`Invalid package name: ${pkg}`); + } + + const target = join(pluginPackageRoot(), "node_modules", scope, name); + await mkdir(dirname(target), { recursive: true }); + await removePath(target); + await symlink(sourceDir, target, "dir"); +} + +async function findNearestPackageJson(startDir: string): Promise { + let current = startDir; + for (;;) { + const candidate = join(current, "package.json"); + if (await pathExists(candidate)) { + return candidate; + } + const parent = dirname(current); + if (parent === current) { + throw new Error(`Could not find package.json above ${startDir}`); + } + current = parent; + } +} + diff --git a/openclaw-plugin/src/isolated.ts b/openclaw-plugin/src/isolated.ts new file mode 100644 index 0000000..0fc6093 --- /dev/null +++ b/openclaw-plugin/src/isolated.ts @@ -0,0 +1,240 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { buildIsolatedRunnerEnv, hashConfig, shouldRestartIsolatedHandle } from "./supervisor/shared.js"; +import type { + ApplyResult, + IsolatedRunnerHandle, + RequiredWorld2AgentPluginConfig, + SensorEntry, + World2AgentPaths, +} from "./types.js"; + +const NO_RESTART_EXIT_CODES = new Set([0, 10, 11, 12]); + +interface ChildHandle extends IsolatedRunnerHandle { + webhookUrl: string; + process: ChildProcessWithoutNullStreams; + stopping: boolean; + lastExitCode: number | null; + restartCount: number; +} + +export interface IsolatedRunnerManagerOptions { + paths: World2AgentPaths; + pluginConfig: RequiredWorld2AgentPluginConfig; + ingestUrl?: string; + hmacSecret: string; + log: (line: string) => void; +} + +export class IsolatedRunnerManager { + private readonly options: IsolatedRunnerManagerOptions; + private readonly handles = new Map(); + private readonly desiredEntries = new Map(); + private readonly runnerBin = fileURLToPath(new URL("./runner/bin.js", import.meta.url)); + + constructor(options: IsolatedRunnerManagerOptions) { + this.options = options; + } + + async apply(entries: SensorEntry[]): Promise { + const desired = entries.filter((entry) => entry.enabled !== false && entry.isolated === true); + const result: ApplyResult = { + started: [], + restarted: [], + stopped: [], + failed: [], + }; + + this.desiredEntries.clear(); + for (const entry of desired) { + this.desiredEntries.set(entry.sensor_id, entry); + } + + for (const [sensorId, handle] of [...this.handles.entries()]) { + if (!this.desiredEntries.has(sensorId)) { + await this.terminate(handle); + result.stopped.push(sensorId); + } + } + + for (const entry of desired) { + if (!this.options.ingestUrl) { + result.failed.push({ + sensor_id: entry.sensor_id, + error: + "isolated runner requires plugin config `ingestUrl` so the subprocess can POST /w2a/ingest", + }); + continue; + } + + const existing = this.handles.get(entry.sensor_id); + if (!existing) { + try { + await this.spawn(entry); + result.started.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + continue; + } + + if (!shouldRestartIsolatedHandle(existing, entry, this.options.ingestUrl)) { + continue; + } + + try { + await this.terminate(existing); + await this.spawn(entry); + result.restarted.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + } + + return result; + } + + async terminateAll(graceMs = 5_000): Promise { + this.desiredEntries.clear(); + for (const handle of [...this.handles.values()]) { + await this.terminate(handle, graceMs); + } + } + + private async spawn(entry: SensorEntry, restartCount = 0): Promise { + if (!this.options.ingestUrl) { + throw new Error( + "isolated runner requires plugin config `ingestUrl` so the subprocess can POST /w2a/ingest", + ); + } + + const proc = spawn(process.execPath, [this.runnerBin], { + env: buildIsolatedRunnerEnv({ + pkg: entry.pkg, + sensorId: entry.sensor_id, + skillId: entry.skill_id, + ingestUrl: this.options.ingestUrl, + hmacSecret: this.options.hmacSecret, + statePath: join(this.options.paths.stateDir, `${entry.sensor_id}.json`), + }), + stdio: ["pipe", "pipe", "pipe"], + }); + + const handle: ChildHandle = { + sensorId: entry.sensor_id, + pkg: entry.pkg, + skillId: entry.skill_id, + isolated: true, + configHash: hashConfig(entry.config), + startedAt: Date.now(), + cleanup: async () => { + await this.terminate(handle); + }, + webhookUrl: this.options.ingestUrl, + process: proc, + stopping: false, + lastExitCode: null, + restartCount, + }; + + this.handles.set(entry.sensor_id, handle); + proc.on("exit", (code, signal) => { + void this.handleExit(handle, code, signal); + }); + pipeStream(proc.stdout, (line) => this.options.log(`[w2a/${entry.sensor_id}] ${line}`)); + pipeStream(proc.stderr, (line) => this.options.log(`[w2a/${entry.sensor_id}] ${line}`)); + proc.stdin.end(JSON.stringify(entry.config ?? {}) + "\n"); + + return handle; + } + + private async terminate(handle: ChildHandle, graceMs = 5_000): Promise { + handle.stopping = true; + if (handle.process.exitCode !== null || handle.process.killed) { + this.handles.delete(handle.sensorId); + return; + } + + const exitPromise = once(handle.process, "exit").catch(() => []); + try { + handle.process.kill("SIGTERM"); + } catch { + this.handles.delete(handle.sensorId); + return; + } + + const timedOut = await Promise.race([ + exitPromise.then(() => false), + delay(graceMs).then(() => true), + ]); + if (timedOut) { + try { + handle.process.kill("SIGKILL"); + } catch { + // no-op + } + await exitPromise; + } + + this.handles.delete(handle.sensorId); + } + + private async handleExit( + handle: ChildHandle, + code: number | null, + signal: NodeJS.Signals | null, + ): Promise { + handle.lastExitCode = code; + + const current = this.handles.get(handle.sensorId); + if (current !== handle) return; + this.handles.delete(handle.sensorId); + this.options.log( + `[w2a/${handle.sensorId}] isolated runner exited code=${String(code)} signal=${String(signal)}`, + ); + + if (handle.stopping) return; + if (code !== null && NO_RESTART_EXIT_CODES.has(code)) return; + + const nextEntry = this.desiredEntries.get(handle.sensorId); + if (!nextEntry) return; + if (!this.options.ingestUrl) return; + + try { + await this.spawn(nextEntry, handle.restartCount + 1); + } catch (error) { + this.options.log( + `[w2a/${handle.sensorId}] isolated restart failed: ${errorMessage(error)}`, + ); + } + } +} + +function pipeStream( + stream: NodeJS.ReadableStream, + onLine: (line: string) => void, +): void { + stream.setEncoding("utf8"); + let buffer = ""; + stream.on("data", (chunk: string) => { + buffer += chunk; + for (;;) { + const index = buffer.indexOf("\n"); + if (index === -1) break; + const line = buffer.slice(0, index).trimEnd(); + buffer = buffer.slice(index + 1); + if (line) onLine(line); + } + }); +} + +function delay(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/openclaw-plugin/src/manifest.ts b/openclaw-plugin/src/manifest.ts new file mode 100644 index 0000000..6109759 --- /dev/null +++ b/openclaw-plugin/src/manifest.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { access, readFile, rm } from "node:fs/promises"; +import { packageToSkillId } from "@world2agent/sdk"; +import { ensureWorld2AgentDirs, writeTextAtomic } from "./paths.js"; +import type { SensorEntry, SensorManifest, World2AgentPaths } from "./types.js"; + +const DEFAULT_MANIFEST: SensorManifest = { + version: 1, + sensors: [], +}; + +export async function readManifest(paths: World2AgentPaths): Promise { + try { + const raw = await readFile(paths.manifestFile, "utf8"); + return parseManifest(JSON.parse(raw) as unknown); + } catch (error) { + if (isMissingFile(error)) { + return structuredClone(DEFAULT_MANIFEST); + } + throw error; + } +} + +export async function writeManifest( + paths: World2AgentPaths, + manifest: SensorManifest, +): Promise { + await ensureWorld2AgentDirs(paths); + const normalized: SensorManifest = { + version: 1, + sensors: manifest.sensors.map(normalizeSensorEntry), + }; + await writeTextAtomic(paths.manifestFile, JSON.stringify(normalized, null, 2) + "\n"); +} + +export function upsertSensorEntry( + manifest: SensorManifest, + entry: SensorEntry, +): SensorManifest { + const normalized = normalizeSensorEntry(entry); + const sensors = manifest.sensors.filter((item) => item.sensor_id !== normalized.sensor_id); + sensors.push(normalized); + sensors.sort((a, b) => a.sensor_id.localeCompare(b.sensor_id)); + return { + version: 1, + sensors, + }; +} + +export function removeSensorEntry( + manifest: SensorManifest, + sensorId: string, +): { + manifest: SensorManifest; + removed: SensorEntry | null; +} { + const removed = manifest.sensors.find((entry) => entry.sensor_id === sensorId) ?? null; + return { + manifest: { + version: 1, + sensors: manifest.sensors.filter((entry) => entry.sensor_id !== sensorId), + }, + removed, + }; +} + +export function normalizeSensorEntry(entry: SensorEntry): SensorEntry { + return { + sensor_id: entry.sensor_id, + pkg: entry.pkg, + skill_id: packageToSkillId(entry.pkg), + enabled: entry.enabled !== false, + isolated: entry.isolated === true, + config: entry.config ?? {}, + }; +} + +export function defaultSensorId(pkg: string): string { + const suffix = pkg.split("/").pop() ?? pkg; + return suffix.replace(/^sensor-/, ""); +} + +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`) + .join(",")}}`; +} + +export function hashConfig(config: unknown): string { + return createHash("sha1").update(stableStringify(config)).digest("hex"); +} + +export async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function removePath(path: string): Promise { + await rm(path, { force: true, recursive: true }); +} + +function parseManifest(raw: unknown): SensorManifest { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("Manifest must be a JSON object"); + } + + const version = (raw as Record).version; + const sensors = (raw as Record).sensors; + if (version !== 1) { + throw new Error(`Unsupported manifest version: ${String(version)}`); + } + if (!Array.isArray(sensors)) { + throw new Error("Manifest field `sensors` must be an array"); + } + + return { + version: 1, + sensors: sensors.map((entry, index) => parseSensorEntry(entry, index)), + }; +} + +function parseSensorEntry(raw: unknown, index: number): SensorEntry { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Manifest sensor[${index}] must be an object`); + } + + const entry = raw as Record; + const sensorId = expectString(entry.sensor_id, `sensor[${index}].sensor_id`); + const pkg = expectString(entry.pkg, `sensor[${index}].pkg`); + const enabled = entry.enabled === undefined ? true : Boolean(entry.enabled); + const isolated = entry.isolated === true; + const config = entry.config; + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`sensor[${index}].config must be an object`); + } + + return { + sensor_id: sensorId, + pkg, + skill_id: + entry.skill_id === undefined + ? packageToSkillId(pkg) + : expectString(entry.skill_id, `sensor[${index}].skill_id`), + enabled, + isolated, + config: config as Record, + }; +} + +function expectString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function isMissingFile(error: unknown): boolean { + return isNodeError(error) && error.code === "ENOENT"; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts b/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts new file mode 100644 index 0000000..86b88da --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/plugin-entry.ts @@ -0,0 +1,6 @@ +import type { OpenClawPluginEntry } from "./types.js"; + +export function definePluginEntry(entry: T): T { + return entry; +} + diff --git a/openclaw-plugin/src/openclaw/plugin-sdk/types.ts b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts new file mode 100644 index 0000000..e96b32a --- /dev/null +++ b/openclaw-plugin/src/openclaw/plugin-sdk/types.ts @@ -0,0 +1,135 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export interface EmbeddedAgentRunRequest { + sessionId: string; + sessionKey?: string; + agentId?: string; + runId: string; + sessionFile: string; + workspaceDir: string; + agentDir?: string; + config?: OpenClawConfig; + prompt: string; + timeoutMs?: number; + [key: string]: unknown; +} + +export interface OpenClawAgentConfig { + id?: string; + name?: string; + skills?: string[]; + [key: string]: unknown; +} + +export interface OpenClawAgentDefaults { + contextInjection?: string; + model?: { + primary?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OpenClawConfig { + agents?: { + defaults?: OpenClawAgentDefaults; + list?: OpenClawAgentConfig[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface OpenClawPluginConfig { + sensorsManifestPath?: string; + stateDir?: string; + sessionDir?: string; + workspaceDir?: string; + ingestUrl?: string; + defaultAgentId?: string; + requestTimeoutMs?: number; + ingestHmacSecretFile?: string; + ingestDedupTtlMs?: number; +} + +export interface OpenClawPluginLogger { + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} + +export interface OpenClawRuntimeAgentSessionApi { + resolveSessionFilePath?(config: OpenClawConfig, sessionId: string): string; +} + +export interface OpenClawRuntimeAgentApi { + runEmbeddedAgent?(request: EmbeddedAgentRunRequest): Promise; + resolveAgentDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentWorkspaceDir?(config: OpenClawConfig, agentId?: string): string; + resolveAgentTimeoutMs?(config: OpenClawConfig): number; + session?: OpenClawRuntimeAgentSessionApi; +} + +export interface OpenClawRuntimeConfigApi { + loadConfig?(): Promise; + writeConfigFile?(config: OpenClawConfig): Promise; +} + +export interface CliCommandBuilder { + description(text: string): CliCommandBuilder; + option(flags: string, description?: string): CliCommandBuilder; + command(name: string): CliCommandBuilder; + action(handler: (...args: any[]) => unknown): CliCommandBuilder; +} + +export interface CliProgram { + command(name: string): CliCommandBuilder; +} + +export type CliRegistrar = (context: { program: CliProgram }) => Promise | void; + +export interface CliCommandDescriptor { + name: string; + description?: string; + hasSubcommands?: boolean; +} + +export interface OpenClawGatewayMethodContext { + payload?: unknown; +} + +export type OpenClawGatewayMethodHandler = ( + context?: OpenClawGatewayMethodContext, +) => Promise | unknown; + +export interface OpenClawHttpRouteRegistration { + path: string; + auth: "plugin"; + handler: (req: IncomingMessage, res: ServerResponse) => Promise | void; +} + +export interface OpenClawPluginApi { + config?: OpenClawConfig; + pluginConfig?: unknown; + registrationMode?: string; + logger?: OpenClawPluginLogger; + resolvePath?(value: string): string; + registerCli?( + registrar: CliRegistrar, + options?: { descriptors?: CliCommandDescriptor[] }, + ): void; + registerGatewayMethod?( + name: string, + handler: OpenClawGatewayMethodHandler, + ): void; + registerHttpRoute?(route: OpenClawHttpRouteRegistration): void; + runtime?: { + agent?: OpenClawRuntimeAgentApi; + config?: OpenClawRuntimeConfigApi; + }; +} + +export interface OpenClawPluginEntry { + id: string; + register(api: OpenClawPluginApi): Promise | void; +} diff --git a/openclaw-plugin/src/paths.ts b/openclaw-plugin/src/paths.ts new file mode 100644 index 0000000..db9e90e --- /dev/null +++ b/openclaw-plugin/src/paths.ts @@ -0,0 +1,93 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { + mkdirSync, + readFileSync, + writeFileSync, + existsSync, + renameSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { RequiredWorld2AgentPluginConfig, World2AgentPaths } from "./types.js"; + +export function getWorld2AgentPaths( + pluginConfig: RequiredWorld2AgentPluginConfig, + env: NodeJS.ProcessEnv = process.env, +): World2AgentPaths { + const openclawHome = env.OPENCLAW_HOME ?? join(homedir(), ".openclaw"); + const baseDir = env.W2A_HOME ?? join(homedir(), ".world2agent"); + + return { + baseDir, + manifestFile: resolvePath(baseDir, pluginConfig.sensorsManifestPath, "sensors.json"), + stateDir: resolvePath(baseDir, pluginConfig.stateDir, "state"), + sessionDir: resolvePath(baseDir, pluginConfig.sessionDir, "sessions"), + openclawHome, + openclawSkillsDir: join(openclawHome, "skills"), + ingestHmacSecretFile: resolvePath( + baseDir, + pluginConfig.ingestHmacSecretFile, + ".openclaw-ingest-secret", + ), + }; +} + +export async function ensureWorld2AgentDirs( + paths: World2AgentPaths, +): Promise { + await mkdir(paths.baseDir, { recursive: true }); + await mkdir(paths.stateDir, { recursive: true }); + await mkdir(paths.sessionDir, { recursive: true }); + await mkdir(paths.openclawSkillsDir, { recursive: true }); +} + +// OpenClaw's plugin loader does NOT await async register(); we must do +// pre-register filesystem work synchronously. +export function ensureWorld2AgentDirsSync(paths: World2AgentPaths): void { + mkdirSync(paths.baseDir, { recursive: true }); + mkdirSync(paths.stateDir, { recursive: true }); + mkdirSync(paths.sessionDir, { recursive: true }); + mkdirSync(paths.openclawSkillsDir, { recursive: true }); +} + +export function loadOrCreateHmacSecretSync(path: string): string { + if (existsSync(path)) { + const existing = readFileSync(path, "utf8").trim(); + if (existing) return existing; + } + mkdirSync(dirname(path), { recursive: true }); + const next = randomBytes(32).toString("hex"); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${next}\n`); + renameSync(tmp, path); + return next; +} + +export async function readTrimmedText(path: string): Promise { + try { + return (await readFile(path, "utf8")).trim() || null; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +export async function writeTextAtomic( + path: string, + content: string, +): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, content, "utf8"); + await rename(tmp, path); +} + +function resolvePath(baseDir: string, override: string | undefined, fallback: string): string { + return override ? resolve(override) : join(baseDir, fallback); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + diff --git a/openclaw-plugin/src/plugin.ts b/openclaw-plugin/src/plugin.ts new file mode 100644 index 0000000..c4deb15 --- /dev/null +++ b/openclaw-plugin/src/plugin.ts @@ -0,0 +1,117 @@ +import { EmbeddedDispatcher, HttpDispatcher } from "./dispatch.js"; +import { + assertContextInjectionCompatible, + loadEffectiveOpenClawConfig, + normalizePluginConfig, +} from "./config.js"; +import { registerWorld2AgentCli } from "./cli.js"; +import { readManifest } from "./manifest.js"; +import { + ensureWorld2AgentDirsSync, + loadOrCreateHmacSecretSync, +} from "./paths.js"; +import { definePluginEntry } from "./openclaw/plugin-sdk/plugin-entry.js"; +import type { OpenClawPluginApi } from "./openclaw/plugin-sdk/types.js"; +import { IsolatedRunnerManager } from "./isolated.js"; +import { SensorRuntime } from "./runtime.js"; +import { getWorld2AgentPaths } from "./paths.js"; + +// register() MUST stay synchronous: OpenClaw's plugin loader logs +// "plugin register returned a promise; async registration is ignored" +// and drops every api.register* call that follows an await. +export function createWorld2AgentPlugin() { + return definePluginEntry({ + id: "world2agent", + register(api: OpenClawPluginApi): void { + const pluginConfig = normalizePluginConfig(api.pluginConfig); + const paths = getWorld2AgentPaths(pluginConfig); + ensureWorld2AgentDirsSync(paths); + + registerWorld2AgentCli({ + api, + paths, + pluginConfig, + }); + + if ((api.registrationMode ?? "full") === "cli-metadata") { + return; + } + + const openclawConfig = api.config ?? {}; + assertContextInjectionCompatible(openclawConfig); + const openclawConfigRef = { current: openclawConfig }; + + const embeddedDispatcher = new EmbeddedDispatcher({ + api, + openclawConfigRef, + pluginConfig, + paths, + }); + + const hmacSecret = loadOrCreateHmacSecretSync(paths.ingestHmacSecretFile); + const httpDispatcher = new HttpDispatcher({ + embeddedDispatcher, + hmacSecret, + dedupTtlMs: pluginConfig.ingestDedupTtlMs, + }); + + api.registerHttpRoute?.(httpDispatcher.createRoute()); + + const runtime = new SensorRuntime({ + dispatcher: embeddedDispatcher, + isolatedRunnerManager: new IsolatedRunnerManager({ + paths, + pluginConfig, + ingestUrl: pluginConfig.ingestUrl, + hmacSecret, + log: (line) => log(api, line), + }), + paths, + log: (line) => log(api, line), + }); + + api.registerGatewayMethod?.("world2agent.reload", async () => { + const nextConfig = await loadEffectiveOpenClawConfig(api); + assertContextInjectionCompatible(nextConfig); + openclawConfigRef.current = nextConfig; + const manifest = await readManifest(paths); + return { + ok: true, + applied: await runtime.applyManifest(manifest.sensors), + }; + }); + + if ((api.registrationMode ?? "full") !== "full") { + return; + } + + // Fire-and-forget: must not be awaited because register() itself is sync. + void runStartup({ api, runtime, paths }); + }, + }); +} + +async function runStartup(opts: { + api: OpenClawPluginApi; + runtime: SensorRuntime; + paths: ReturnType; +}): Promise { + try { + const manifest = await readManifest(opts.paths); + const applied = await opts.runtime.applyManifest(manifest.sensors); + if (applied.failed.length > 0) { + log( + opts.api, + `[w2a] startup completed with failures: ${JSON.stringify(applied.failed)}`, + ); + } + } catch (error) { + const logger = opts.api.logger ?? console; + logger.error("[w2a] startup failed:", error); + } +} + +function log(api: OpenClawPluginApi, line: string): void { + const logger = api.logger ?? console; + logger.info(line); +} diff --git a/openclaw-plugin/src/prompt.ts b/openclaw-plugin/src/prompt.ts new file mode 100644 index 0000000..d78772a --- /dev/null +++ b/openclaw-plugin/src/prompt.ts @@ -0,0 +1,44 @@ +import type { Attachment, W2ASignal } from "@world2agent/sdk"; + +export function renderSignalPrompt( + signal: W2ASignal, + options: { + skillId: string; + useSkillPrefix: boolean; + }, +): string { + const attachmentLines = renderAttachmentLines(signal.attachments ?? []); + const body = [ + "# World2Agent Signal", + "", + `Event: ${signal.event.type}`, + signal.event.summary, + attachmentLines ? "" : null, + attachmentLines || null, + "", + "Signal JSON:", + "```json", + JSON.stringify(signal, null, 2), + "```", + ] + .filter((part): part is string => part !== null) + .join("\n"); + + if (!options.useSkillPrefix) { + return body; + } + + return `Use skill: ${options.skillId}\n\n${body}`; +} + +function renderAttachmentLines(attachments: Attachment[]): string { + if (attachments.length === 0) return ""; + + const lines = attachments.map((attachment) => { + const locator = attachment.type === "reference" ? attachment.uri : "inline"; + return `- ${attachment.mime_type} ${attachment.description} (${locator})`; + }); + + return ["Attachments:", ...lines].join("\n"); +} + diff --git a/openclaw-plugin/src/runner/bin.ts b/openclaw-plugin/src/runner/bin.ts new file mode 100644 index 0000000..4e4046f --- /dev/null +++ b/openclaw-plugin/src/runner/bin.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { FileSensorStore, startSensor, type SensorSpec } from "@world2agent/sdk"; +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; +import { ingestTransport } from "./http-transport.js"; +import { readJsonFromStdin } from "./config-stream.js"; + +const EXIT_CONFIG_ERROR = 10; +const EXIT_IMPORT_ERROR = 11; +const EXIT_START_ERROR = 12; + +async function main(): Promise { + const env = requireEnv([ + "W2A_PACKAGE", + "W2A_INGEST_URL", + "W2A_HMAC_SECRET", + "W2A_SENSOR_ID", + "W2A_SKILL_ID", + "W2A_STATE_PATH", + ]); + + let config: Record; + try { + config = await readJsonFromStdin(); + } catch (error) { + console.error(error); + process.exit(EXIT_CONFIG_ERROR); + } + + let spec: SensorSpec>; + try { + spec = await loadSensorSpec(env.W2A_PACKAGE); + } catch (error) { + console.error(error); + process.exit(EXIT_IMPORT_ERROR); + } + + const transport = ingestTransport({ + url: env.W2A_INGEST_URL, + hmacSecret: env.W2A_HMAC_SECRET, + sensorId: env.W2A_SENSOR_ID, + skillId: env.W2A_SKILL_ID, + timeoutMs: 120_000, + }); + const store = new FileSensorStore({ path: env.W2A_STATE_PATH }); + + let cleanup: (() => Promise | void) | undefined; + try { + cleanup = await startSensor(spec, { + config, + onSignal: transport, + store, + logger: console, + logEmits: true, + }); + } catch (error) { + console.error(error); + await store.flush().catch(() => {}); + process.exit(EXIT_START_ERROR); + } + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + + try { + await cleanup?.(); + await store.flush(); + } catch (error) { + console.error(error); + process.exit(1); + } + + process.exit(0); + }; + + process.on("SIGTERM", () => { + void shutdown(); + }); + process.on("SIGINT", () => { + void shutdown(); + }); + + const watchdog = setInterval(() => { + if (process.ppid === 1) { + console.error("[w2a-openclaw-runner] parent died; shutting down"); + void shutdown(); + } + }, 5_000); + watchdog.unref(); + + await new Promise(() => {}); +} + +async function loadSensorSpec(pkg: string): Promise>> { + const module = await import(resolveImportTarget(pkg)); + const spec = module.default as SensorSpec> | undefined; + if (!spec || typeof spec.start !== "function") { + throw new Error(`${pkg} does not export a valid default SensorSpec`); + } + return spec; +} + +function resolveImportTarget(pkg: string): string { + if (pkg.startsWith(".") || pkg.startsWith("/") || isAbsolute(pkg)) { + return pathToFileURL(resolve(pkg)).href; + } + return pkg; +} + +function requireEnv(keys: string[]): Record { + const values: Record = {}; + for (const key of keys) { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required env var: ${key}`); + } + values[key] = value; + } + return values; +} + +main().catch((error) => { + console.error(error); + process.exit(99); +}); + diff --git a/openclaw-plugin/src/runner/config-stream.ts b/openclaw-plugin/src/runner/config-stream.ts new file mode 100644 index 0000000..f0170d0 --- /dev/null +++ b/openclaw-plugin/src/runner/config-stream.ts @@ -0,0 +1,4 @@ +import { readJsonFromStdin } from "../supervisor/shared.js"; + +export { readJsonFromStdin }; + diff --git a/openclaw-plugin/src/runner/http-transport.ts b/openclaw-plugin/src/runner/http-transport.ts new file mode 100644 index 0000000..5b369db --- /dev/null +++ b/openclaw-plugin/src/runner/http-transport.ts @@ -0,0 +1,64 @@ +import { createHmac } from "node:crypto"; +import type { W2ASignal } from "@world2agent/sdk"; +import type { HttpIngestEnvelope } from "../types.js"; + +export interface IngestTransportOptions { + url: string; + hmacSecret: string; + sensorId: string; + skillId: string; + timeoutMs: number; + retries?: number; + retryDelayMs?: number; +} + +export function ingestTransport(options: IngestTransportOptions) { + const retries = options.retries ?? 2; + const retryDelayMs = options.retryDelayMs ?? 500; + + return async (signal: W2ASignal): Promise => { + const envelope: HttpIngestEnvelope = { + sensor_id: options.sensorId, + skill_id: options.skillId, + signal, + }; + const body = JSON.stringify(envelope); + const headers: Record = { + "Content-Type": "application/json", + "X-Request-ID": signal.signal_id, + "X-Webhook-Signature": createHmac("sha256", options.hmacSecret) + .update(body) + .digest("hex"), + }; + + let lastError: unknown; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const response = await fetch(options.url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(options.timeoutMs), + }); + if (response.ok) return; + if (response.status >= 400 && response.status < 500) { + throw new Error(`HTTP ${response.status}: ${await response.text().catch(() => "")}`); + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + + if (attempt < retries) { + await sleep(retryDelayMs * 2 ** attempt); + } + } + + throw lastError; + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + diff --git a/openclaw-plugin/src/runtime.ts b/openclaw-plugin/src/runtime.ts new file mode 100644 index 0000000..dd9785b --- /dev/null +++ b/openclaw-plugin/src/runtime.ts @@ -0,0 +1,160 @@ +import { FileSensorStore, startSensor, type SensorSpec } from "@world2agent/sdk"; +import { join } from "node:path"; +import type { Dispatcher, ApplyResult, RuntimeHandle, SensorEntry, World2AgentPaths } from "./types.js"; +import { hashConfig } from "./manifest.js"; +import { IsolatedRunnerManager } from "./isolated.js"; +import { resolveImportTarget } from "./supervisor/shared.js"; + +export interface SensorRuntimeOptions { + dispatcher: Dispatcher; + isolatedRunnerManager: IsolatedRunnerManager; + paths: World2AgentPaths; + log: (line: string) => void; +} + +export class SensorRuntime { + private readonly dispatcher: Dispatcher; + private readonly isolatedRunnerManager: IsolatedRunnerManager; + private readonly paths: World2AgentPaths; + private readonly log: (line: string) => void; + private readonly handles = new Map(); + + constructor(options: SensorRuntimeOptions) { + this.dispatcher = options.dispatcher; + this.isolatedRunnerManager = options.isolatedRunnerManager; + this.paths = options.paths; + this.log = options.log; + } + + async applyManifest(entries: SensorEntry[]): Promise { + const desired = entries.filter((entry) => entry.enabled !== false); + const result: ApplyResult = { + started: [], + restarted: [], + stopped: [], + failed: [], + }; + + const desiredInProcess = desired.filter((entry) => entry.isolated !== true); + + for (const [sensorId, handle] of [...this.handles.entries()]) { + if (!desiredInProcess.some((entry) => entry.sensor_id === sensorId)) { + await this.stopHandle(handle); + result.stopped.push(sensorId); + } + } + + for (const entry of desiredInProcess) { + const existing = this.handles.get(entry.sensor_id); + if (!existing) { + try { + await this.startHandle(entry); + result.started.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + continue; + } + + if (matchesHandle(existing, entry)) { + continue; + } + + try { + await this.stopHandle(existing); + await this.startHandle(entry); + result.restarted.push(entry.sensor_id); + } catch (error) { + result.failed.push({ sensor_id: entry.sensor_id, error: errorMessage(error) }); + } + } + + const isolatedResult = await this.isolatedRunnerManager.apply(desired); + mergeApplyResult(result, isolatedResult); + + return result; + } + + async stopAll(): Promise { + for (const handle of [...this.handles.values()]) { + await this.stopHandle(handle); + } + await this.isolatedRunnerManager.terminateAll(); + } + + private async startHandle(entry: SensorEntry): Promise { + const spec = await loadSensorSpec(entry.pkg); + const store = new FileSensorStore({ + path: join(this.paths.stateDir, `${entry.sensor_id}.json`), + }); + const cleanup = await startSensor(spec, { + config: entry.config, + store, + logger: console, + logEmits: true, + onSignal: async (signal) => { + try { + await this.dispatcher.dispatch({ + sensorId: entry.sensor_id, + skillId: entry.skill_id, + signal, + }); + } catch (error) { + this.log( + `[w2a/${entry.sensor_id}] dispatch failed: ${errorMessage(error)}`, + ); + } + }, + }); + + const handle: RuntimeHandle = { + sensorId: entry.sensor_id, + pkg: entry.pkg, + skillId: entry.skill_id, + isolated: false, + configHash: hashConfig(entry.config), + startedAt: Date.now(), + cleanup, + flush: () => store.flush(), + }; + this.handles.set(entry.sensor_id, handle); + } + + private async stopHandle(handle: RuntimeHandle): Promise { + try { + await handle.cleanup(); + await handle.flush?.(); + } finally { + this.handles.delete(handle.sensorId); + } + } +} + +async function loadSensorSpec(pkg: string): Promise>> { + const module = await import(resolveImportTarget(pkg)); + const spec = module.default as SensorSpec> | undefined; + if (!spec || typeof spec.start !== "function") { + throw new Error(`${pkg} does not export a valid default SensorSpec`); + } + return spec; +} + +function matchesHandle(handle: RuntimeHandle, entry: SensorEntry): boolean { + return ( + handle.pkg === entry.pkg && + handle.skillId === entry.skill_id && + handle.configHash === hashConfig(entry.config) && + handle.isolated === false + ); +} + +function mergeApplyResult(target: ApplyResult, update: ApplyResult): void { + target.started.push(...update.started); + target.restarted.push(...update.restarted); + target.stopped.push(...update.stopped); + target.failed.push(...update.failed); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/openclaw-plugin/src/supervisor/shared.ts b/openclaw-plugin/src/supervisor/shared.ts new file mode 100644 index 0000000..e61df45 --- /dev/null +++ b/openclaw-plugin/src/supervisor/shared.ts @@ -0,0 +1,117 @@ +import { createHash } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { SensorEntry } from "../types.js"; + +/** + * Copied in minimal form from hermes-sensor-bridge: + * - src/supervisor/manifest.ts + * - src/supervisor/spawn.ts + * - src/runner/config-stream.ts + * - src/runner/bin.ts + */ + +export interface IsolatedProcessMeta { + sensorId: string; + pkg: string; + skillId: string; + webhookUrl: string; + configHash: string; +} + +export interface IsolatedRunnerEnvInput { + pkg: string; + sensorId: string; + skillId: string; + ingestUrl: string; + hmacSecret: string; + statePath: string; + logLevel?: string; +} + +export function buildIsolatedRunnerEnv( + input: IsolatedRunnerEnvInput, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[key] = value; + } + } + + return { + ...env, + W2A_PACKAGE: input.pkg, + W2A_INGEST_URL: input.ingestUrl, + W2A_HMAC_SECRET: input.hmacSecret, + W2A_SENSOR_ID: input.sensorId, + W2A_SKILL_ID: input.skillId, + W2A_STATE_PATH: input.statePath, + W2A_LOG_LEVEL: input.logLevel ?? process.env.W2A_LOG_LEVEL ?? "info", + }; +} + +export function shouldRestartIsolatedHandle( + handle: IsolatedProcessMeta, + entry: SensorEntry, + ingestUrl: string, +): boolean { + return !( + handle.pkg === entry.pkg && + handle.skillId === entry.skill_id && + handle.webhookUrl === ingestUrl && + handle.configHash === hashConfig(entry.config) + ); +} + +export async function readJsonFromStdin( + stdin: AsyncIterable = process.stdin, +): Promise> { + let raw = ""; + for await (const chunk of stdin) { + raw += chunk.toString(); + } + + const text = raw.trim(); + if (!text) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error( + `Invalid sensor config JSON on stdin: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Sensor config JSON must be an object"); + } + + return parsed as Record; +} + +export function resolveImportTarget(pkg: string): string { + if (pkg.startsWith(".") || pkg.startsWith("/") || isAbsolute(pkg)) { + return pathToFileURL(resolve(pkg)).href; + } + return pkg; +} + +export function hashConfig(config: unknown): string { + return createHash("sha1").update(stableStringify(config)).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + const obj = value as Record; + return `{${Object.keys(obj) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`) + .join(",")}}`; +} diff --git a/openclaw-plugin/src/types.ts b/openclaw-plugin/src/types.ts new file mode 100644 index 0000000..ec8a10b --- /dev/null +++ b/openclaw-plugin/src/types.ts @@ -0,0 +1,104 @@ +import type { CleanupFn, W2ASignal } from "@world2agent/sdk"; +import type { + OpenClawConfig, + OpenClawPluginApi, + OpenClawPluginConfig, +} from "./openclaw/plugin-sdk/types.js"; + +export interface World2AgentPaths { + baseDir: string; + manifestFile: string; + stateDir: string; + sessionDir: string; + openclawHome: string; + openclawSkillsDir: string; + ingestHmacSecretFile: string; +} + +export interface SensorEntry { + sensor_id: string; + pkg: string; + skill_id: string; + enabled: boolean; + isolated?: boolean; + config: Record; +} + +export interface SensorManifest { + version: 1; + sensors: SensorEntry[]; +} + +export interface DispatcherDispatchInput { + sensorId: string; + skillId: string; + signal: W2ASignal; + sessionId?: string; +} + +export interface Dispatcher { + dispatch(input: DispatcherDispatchInput): Promise; +} + +export interface EmbeddedDispatcherOptions { + api: OpenClawPluginApi; + openclawConfigRef: { current: OpenClawConfig }; + pluginConfig: RequiredWorld2AgentPluginConfig; + paths: World2AgentPaths; +} + +export interface HttpIngestEnvelope { + sensor_id: string; + skill_id: string; + signal: W2ASignal; +} + +export interface HttpDispatcherOptions { + embeddedDispatcher: Dispatcher; + hmacSecret: string; + dedupTtlMs: number; +} + +export interface RuntimeHandle { + sensorId: string; + pkg: string; + skillId: string; + isolated: boolean; + configHash: string; + startedAt: number; + cleanup: CleanupFn; + flush?: () => Promise; +} + +export interface ApplyResult { + started: string[]; + restarted: string[]; + stopped: string[]; + failed: Array<{ sensor_id: string; error: string }>; +} + +export interface RequiredWorld2AgentPluginConfig { + sensorsManifestPath?: string; + stateDir?: string; + sessionDir?: string; + workspaceDir?: string; + ingestUrl?: string; + defaultAgentId: string; + provider?: string; + model?: string; + requestTimeoutMs: number; + ingestHmacSecretFile?: string; + ingestDedupTtlMs: number; +} + +export interface IsolatedRunnerHandle { + sensorId: string; + pkg: string; + skillId: string; + isolated: true; + configHash: string; + startedAt: number; + cleanup: CleanupFn; +} + +export type ParsedPluginConfig = OpenClawPluginConfig; diff --git a/openclaw-plugin/test/context-injection.test.ts b/openclaw-plugin/test/context-injection.test.ts new file mode 100644 index 0000000..1df44f1 --- /dev/null +++ b/openclaw-plugin/test/context-injection.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createWorld2AgentPlugin } from "../src/plugin.js"; + +const ORIGINAL_W2A_HOME = process.env.W2A_HOME; +const ORIGINAL_OPENCLAW_HOME = process.env.OPENCLAW_HOME; + +describe("contextInjection startup check", () => { + afterEach(() => { + process.env.W2A_HOME = ORIGINAL_W2A_HOME; + process.env.OPENCLAW_HOME = ORIGINAL_OPENCLAW_HOME; + }); + + it("fails register() synchronously when agents.defaults.contextInjection is not continuation-skip", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-register-")); + process.env.W2A_HOME = join(root, "w2a"); + process.env.OPENCLAW_HOME = join(root, "openclaw"); + + const plugin = createWorld2AgentPlugin(); + expect(() => + plugin.register({ + config: { + agents: { + defaults: { + contextInjection: "always", + }, + }, + }, + pluginConfig: {}, + }), + ).toThrow( + "OpenClaw config field `agents.defaults.contextInjection` must be set to \"continuation-skip\"", + ); + }); + + it("register() returns synchronously (not a promise) — OpenClaw drops async registers", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-sync-")); + process.env.W2A_HOME = join(root, "w2a"); + process.env.OPENCLAW_HOME = join(root, "openclaw"); + + const plugin = createWorld2AgentPlugin(); + const result = plugin.register({ + registrationMode: "cli-metadata", + pluginConfig: {}, + registerCli: () => {}, + }); + expect(result).toBeUndefined(); + }); +}); + diff --git a/openclaw-plugin/test/dispatch.test.ts b/openclaw-plugin/test/dispatch.test.ts new file mode 100644 index 0000000..5cfb446 --- /dev/null +++ b/openclaw-plugin/test/dispatch.test.ts @@ -0,0 +1,146 @@ +import { createHmac } from "node:crypto"; +import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; +import { EmbeddedDispatcher, HttpDispatcher } from "../src/dispatch.js"; +import type { EmbeddedAgentRunRequest } from "../src/openclaw/plugin-sdk/types.js"; +import type { HttpIngestEnvelope, World2AgentPaths } from "../src/types.js"; + +const TEST_SIGNAL = { + signal_id: "sig-1", + schema_version: "w2a/0.1" as const, + emitted_at: Date.now(), + source: { + sensor_id: "@world2agent/sensor-fake-tick", + sensor_version: "0.0.1", + source_type: "fake", + user_identity: "unknown", + package: "@world2agent/sensor-fake-tick", + }, + event: { + type: "news.item.created", + occurred_at: Date.now(), + summary: "A fake tick signal fired for dispatcher tests.", + }, +}; + +describe("EmbeddedDispatcher", () => { + it("serializes by sensor session and renders the prompt-prefix fallback", async () => { + const calls: EmbeddedAgentRunRequest[] = []; + const dispatcher = new EmbeddedDispatcher({ + api: { + runtime: { + agent: { + runEmbeddedAgent: vi.fn(async (request: EmbeddedAgentRunRequest) => { + calls.push(request); + return { ok: true }; + }), + }, + }, + }, + openclawConfigRef: { + current: { + agents: { + defaults: { + contextInjection: "continuation-skip", + }, + list: [], + }, + }, + }, + pluginConfig: { + defaultAgentId: "world2agent", + requestTimeoutMs: 12_345, + ingestDedupTtlMs: 3_600_000, + }, + paths: makePaths("/tmp/w2a-openclaw-dispatch"), + }); + + await dispatcher.dispatch({ + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.sessionId).toBe("w2a-fake-tick"); + expect(calls[0]?.sessionKey).toBe("w2a:fake-tick"); + expect(calls[0]?.timeoutMs).toBe(12_345); + expect(calls[0]?.prompt.startsWith("Use skill: world2agent-sensor-fake-tick")).toBe( + true, + ); + }); +}); + +describe("HttpDispatcher", () => { + it("validates HMAC and dedups X-Request-ID", async () => { + const dispatch = vi.fn(async () => ({ ok: true })); + const http = new HttpDispatcher({ + embeddedDispatcher: { dispatch }, + hmacSecret: "secret", + dedupTtlMs: 60_000, + }); + + const payload: HttpIngestEnvelope = { + sensor_id: "fake-tick", + skill_id: "world2agent-sensor-fake-tick", + signal: TEST_SIGNAL, + }; + const body = JSON.stringify(payload); + const signature = createHmac("sha256", "secret").update(body).digest("hex"); + + const first = await invokeRoute(http, body, "req-1", signature); + const second = await invokeRoute(http, body, "req-1", signature); + + expect(first.statusCode).toBe(202); + expect(second.statusCode).toBe(202); + expect(second.body).toContain("\"deduped\": true"); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); + +async function invokeRoute( + http: HttpDispatcher, + body: string, + requestId: string, + signature: string, +): Promise<{ statusCode: number; body: string }> { + const req = new PassThrough() as PassThrough & { + headers: Record; + method: string; + }; + req.headers = { + "x-request-id": requestId, + "x-webhook-signature": signature, + }; + req.method = "POST"; + req.end(body); + + let responseBody = ""; + const res = { + statusCode: 200, + setHeader: vi.fn(), + end: vi.fn((value?: string) => { + responseBody = value ?? ""; + }), + }; + + await http.handle(req as any, res as any); + + return { + statusCode: res.statusCode, + body: responseBody, + }; +} + +function makePaths(root: string): World2AgentPaths { + return { + baseDir: root, + manifestFile: `${root}/sensors.json`, + stateDir: `${root}/state`, + sessionDir: `${root}/sessions`, + openclawHome: `${root}/.openclaw`, + openclawSkillsDir: `${root}/.openclaw/skills`, + ingestHmacSecretFile: `${root}/.secret`, + }; +} + diff --git a/openclaw-plugin/test/manifest.test.ts b/openclaw-plugin/test/manifest.test.ts new file mode 100644 index 0000000..52f9ed1 --- /dev/null +++ b/openclaw-plugin/test/manifest.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + readManifest, + removeSensorEntry, + upsertSensorEntry, + writeManifest, +} from "../src/manifest.js"; +import type { World2AgentPaths } from "../src/types.js"; + +describe("manifest helpers", () => { + it("writes and reads a normalized manifest", async () => { + const root = await mkdtemp(join(tmpdir(), "w2a-openclaw-manifest-")); + const paths = makePaths(root); + + await writeManifest(paths, { + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "ignored-on-write", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + + const manifest = await readManifest(paths); + expect(manifest).toEqual({ + version: 1, + sensors: [ + { + sensor_id: "hackernews", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + isolated: true, + config: { interval_ms: 30_000 }, + }, + ], + }); + }); + + it("upserts and removes entries by sensor id", () => { + const initial = { + version: 1 as const, + sensors: [], + }; + + const afterInsert = upsertSensorEntry(initial, { + sensor_id: "news", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + config: {}, + }); + const afterUpdate = upsertSensorEntry(afterInsert, { + sensor_id: "news", + pkg: "@world2agent/sensor-hackernews", + skill_id: "world2agent-sensor-hackernews", + enabled: true, + config: { interval_ms: 60_000 }, + }); + + expect(afterUpdate.sensors).toHaveLength(1); + expect(afterUpdate.sensors[0]?.config).toEqual({ interval_ms: 60_000 }); + + const removed = removeSensorEntry(afterUpdate, "news"); + expect(removed.removed?.sensor_id).toBe("news"); + expect(removed.manifest.sensors).toEqual([]); + }); +}); + +function makePaths(root: string): World2AgentPaths { + return { + baseDir: root, + manifestFile: join(root, "sensors.json"), + stateDir: join(root, "state"), + sessionDir: join(root, "sessions"), + openclawHome: join(root, ".openclaw"), + openclawSkillsDir: join(root, ".openclaw", "skills"), + ingestHmacSecretFile: join(root, ".secret"), + }; +} + diff --git a/openclaw-plugin/test/supervisor-shared.test.ts b/openclaw-plugin/test/supervisor-shared.test.ts new file mode 100644 index 0000000..a09adb4 --- /dev/null +++ b/openclaw-plugin/test/supervisor-shared.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + buildIsolatedRunnerEnv, + hashConfig, + readJsonFromStdin, + shouldRestartIsolatedHandle, +} from "../src/supervisor/shared.js"; + +describe("supervisor shared boundary", () => { + it("builds the isolated runner env expected by the reused runner contract", () => { + const env = buildIsolatedRunnerEnv({ + pkg: "@world2agent/sensor-fake-tick", + sensorId: "fake-tick", + skillId: "world2agent-sensor-fake-tick", + ingestUrl: "http://127.0.0.1:3333/w2a/ingest", + hmacSecret: "secret", + statePath: "/tmp/fake-tick.json", + }); + + expect(env.W2A_PACKAGE).toBe("@world2agent/sensor-fake-tick"); + expect(env.W2A_SENSOR_ID).toBe("fake-tick"); + expect(env.W2A_SKILL_ID).toBe("world2agent-sensor-fake-tick"); + expect(env.W2A_INGEST_URL).toBe("http://127.0.0.1:3333/w2a/ingest"); + }); + + it("detects whether an isolated handle needs restart", () => { + const same = shouldRestartIsolatedHandle( + { + sensorId: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skillId: "world2agent-sensor-fake-tick", + webhookUrl: "http://127.0.0.1:3333/w2a/ingest", + configHash: hashConfig({ + interval_ms: 60_000, + }), + }, + { + sensor_id: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skill_id: "world2agent-sensor-fake-tick", + enabled: true, + isolated: true, + config: { + interval_ms: 60_000, + }, + }, + "http://127.0.0.1:3333/w2a/ingest", + ); + + const changed = shouldRestartIsolatedHandle( + { + sensorId: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skillId: "world2agent-sensor-fake-tick", + webhookUrl: "http://127.0.0.1:3333/w2a/ingest", + configHash: "old", + }, + { + sensor_id: "fake-tick", + pkg: "@world2agent/sensor-fake-tick", + skill_id: "world2agent-sensor-fake-tick", + enabled: true, + isolated: true, + config: { + interval_ms: 60_000, + }, + }, + "http://127.0.0.1:3333/w2a/ingest", + ); + + expect(same).toBe(false); + expect(changed).toBe(true); + }); + + it("parses config JSON from stdin-compatible streams", async () => { + async function* chunks() { + yield '{"interval_ms":60000}'; + } + + await expect(readJsonFromStdin(chunks())).resolves.toEqual({ + interval_ms: 60_000, + }); + }); +}); diff --git a/openclaw-plugin/tsconfig.json b/openclaw-plugin/tsconfig.json new file mode 100644 index 0000000..3205970 --- /dev/null +++ b/openclaw-plugin/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "dist", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*" + ] +} + diff --git a/openclaw-plugin/vitest.config.ts b/openclaw-plugin/vitest.config.ts new file mode 100644 index 0000000..2233cef --- /dev/null +++ b/openclaw-plugin/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); +