Compare commits
329 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d44c983f | |||
| 90c6744925 | |||
| a29b18c003 | |||
| 45d868685f | |||
| 0fdac31383 | |||
| cbed0e065c | |||
| e618794a96 | |||
| 39f7dbfe02 | |||
| c03adfb41a | |||
| 31c0b04c49 | |||
| b1bbf3fff1 | |||
| 53575f2013 | |||
| eb6fa0dacf | |||
| cb917b7f05 | |||
| 10c7ae1eca | |||
| 5dd264d2fb | |||
| 58171c8918 | |||
| cceefe833a | |||
| 0cfd448bab | |||
| 27f4ab2fb2 | |||
| 564be6b402 | |||
| 7e0b3f16e3 | |||
| ca2ae342db | |||
| d33db186d0 | |||
| da61aa8a58 | |||
| c53b11dccd | |||
| a1346a519a | |||
| 7ef6623bf3 | |||
| eaa9e1c661 | |||
| 69b2f8cd8b | |||
| df65ed7e9e | |||
| 2ed9d633b3 | |||
| a0b12f2ba7 | |||
| 712e231725 | |||
| da9f24dd2e | |||
| 47bb568cb2 | |||
| 4b4718c8df | |||
| 4e690e09c7 | |||
| d06632ba45 | |||
| d82c042b09 | |||
| bce643a0bd | |||
| a0c5e28f3b | |||
| 36b6ea1446 | |||
| 192df12d60 | |||
| 535ef8991c | |||
| 6f0b4caa26 | |||
| 78a7ff2d50 | |||
| 611dff985d | |||
| 38b6cee020 | |||
| 1aadf26f9a | |||
| 5a453eacbd | |||
| 764cd5a310 | |||
| a1628d89ec | |||
| 8483e01a68 | |||
| 01b4f42f9a | |||
| 4cb4053993 | |||
| a4408a917e | |||
| 20730af20b | |||
| 311f57a2cd | |||
| 675764e866 | |||
| b01273cfc6 | |||
| d6cbaea434 | |||
| 03159f3942 | |||
| 344f54b84d | |||
| ac03803d12 | |||
| 861b90f79c | |||
| d92fc85555 | |||
| f7041fbee3 | |||
| dc6e4a5b13 | |||
| 3f20c43308 | |||
| a81cf35a6f | |||
| a909019078 | |||
| 1087033abd | |||
| 47f52cd233 | |||
| bed69339c1 | |||
| b788616d9c | |||
| 90d426f9ad | |||
| d6eefe2e75 | |||
| 262bca9bdd | |||
| 64de4b6d6a | |||
| d0d83a2020 | |||
| fe842b5f14 | |||
| c596658b8d | |||
| 10481097f8 | |||
| f4391c1725 | |||
| 9597cf1890 | |||
| edf7ad9b7d | |||
| 9f154efa8d | |||
| c5facb8477 | |||
| cd80c7e7ff | |||
| 53e30475e2 | |||
| 0ec7711bc2 | |||
| 273973d374 | |||
| 6fd9ec97de | |||
| 08e3357480 | |||
| 15e3e63705 | |||
| b044c149c1 | |||
| 8a51891ed5 | |||
| 258d615c4d | |||
| 37a138c554 | |||
| 4894d907fa | |||
| baf1c8ea13 | |||
| cf327f60ba | |||
| d14e734e9c | |||
| d768c1f81c | |||
| 67f6a13c5a | |||
| 551647aa96 | |||
| 60bb475355 | |||
| 1708b11fab | |||
| b678308d96 | |||
| 75ed72e807 | |||
| 57334cd7d8 | |||
| 2e97d0dd95 | |||
| 773ab319ef | |||
| ecbb3bcc1a | |||
| 09f4abdd61 | |||
| 7d9397099b | |||
| 4da6a7f212 | |||
| c81e9866ff | |||
| 9a4b2266cc | |||
| f877e7e74c | |||
| 051fdcc428 | |||
| 7f863e22b0 | |||
| 16ccd5a874 | |||
| ed9cd846d0 | |||
| 03d7641b0e | |||
| 63c6080d50 | |||
| 792ce7b5b4 | |||
| 5c0255477c | |||
| d8477cbb3f | |||
| fae8de9ae0 | |||
| aa17bdbe4a | |||
| 45b5c23825 | |||
| 0f9c602591 | |||
| cc1eaf130b | |||
| 47fc6a0806 | |||
| 4380d74d49 | |||
| 820d614757 | |||
| 485cd0c512 | |||
| 14897e8de7 | |||
| 7671c1dd10 | |||
| d879c7c641 | |||
| ea1ccf4896 | |||
| f46b9c996f | |||
| 06290b49b2 | |||
| ba2eb583c0 | |||
| f413e314b9 | |||
| 8944b75e16 | |||
| 86622ebea9 | |||
| 67e9554645 | |||
| 060ede8aaa | |||
| b84d7796be | |||
| bde9cbb058 | |||
| 4e7a833a24 | |||
| bb60cab76d | |||
| 5e3a86fd2f | |||
| e8637c79b3 | |||
| 0e69660c41 | |||
| c5b89fbaea | |||
| 9203d583f9 | |||
| c0a3801086 | |||
| cb119874dc | |||
| 4d94b05ac5 | |||
| 13b4993289 | |||
| 59e5f12bf9 | |||
| 2ef109f00a | |||
| e8d1725187 | |||
| fce4d76a78 | |||
| 68b9d89ee7 | |||
| 09c7cb5d34 | |||
| b50d2ce93c | |||
| 04aa856fc0 | |||
| 103d02f98c | |||
| 56f73ae080 | |||
| 58590087de | |||
| 7e1557b8c9 | |||
| 363334253b | |||
| 4807e40cbd | |||
| 8e439e2d81 | |||
| a74067d00b | |||
| f6a854bd37 | |||
| 2e53033f22 | |||
| fe56700026 | |||
| 301fe18909 | |||
| 6a251d8d74 | |||
| 5ae367aadd | |||
| cec404225d | |||
| e1301c31e7 | |||
| 4c5a2c3c6d | |||
| 45ec5aaf2b | |||
| 8e33ebe471 | |||
| 3dbb6be270 | |||
| 1560f02561 | |||
| eb855f75ce | |||
| e45729a430 | |||
| e4915cb107 | |||
| b1533bc80c | |||
| b50c4c2c44 | |||
| 2f3b919b94 | |||
| d00ed73026 | |||
| c3a4251a60 | |||
| 6daf40d3f4 | |||
| 79659b2b14 | |||
| 62a248eb99 | |||
| 22b0f36350 | |||
| dbfdf60a42 | |||
| 296210636d | |||
| 840b768d97 | |||
| 5416cabdf8 | |||
| 65d5a91242 | |||
| ce833cd6de | |||
| 42cf32c386 | |||
| 77a3930b72 | |||
| 4c75eca580 | |||
| 081b1aa1ed | |||
| 8315c58675 | |||
| 96aad965ab | |||
| eb9a968336 | |||
| 9c142993b8 | |||
| 0ab5f4c43b | |||
| 71e45ceecc | |||
| a481ed00f5 | |||
| 1ba525f94d | |||
| 79176cc4e5 | |||
| 4b259ab81b | |||
| 00e8e88a7c | |||
| 7e7ca43a79 | |||
| 85b075d0cc | |||
| 185c393459 | |||
| 490cb5174d | |||
| 473a27470f | |||
| 7d8aeaaf06 | |||
| cf311978ea | |||
| 7b5153f214 | |||
| b402770f63 | |||
| f53e4e9ffb | |||
| c397a02c9a | |||
| 1aef45bc06 | |||
| 4b71de384c | |||
| e3385a6578 | |||
| 2ca2d5ab1c | |||
| 4fd29a35bb | |||
| 8b5ebff67b | |||
| f692288301 | |||
| 452a8c9db9 | |||
| 69590de276 | |||
| 46eba86b45 | |||
| ecb2053fdd | |||
| 125dc322f5 | |||
| 5df9aacf68 | |||
| a288f3066f | |||
| 327f0526d1 | |||
| da53015ef5 | |||
| a97cec0018 | |||
| 892a9c24b0 | |||
| eac86c2081 | |||
| cf4853e2b8 | |||
| 736ec9690f | |||
| d671d7a0a2 | |||
| 6d52b47076 | |||
| db6c513d1e | |||
| 6632fd1ea9 | |||
| 39b5ffdaa6 | |||
| 58c100f66f | |||
| 10d9549764 | |||
| 151ee6014a | |||
| 8bdda7a651 | |||
| d08dafb08f | |||
| 0ed675b1df | |||
| 0231cac957 | |||
| 3d30ba18a2 | |||
| da0ba1b73a | |||
| b096ad267e | |||
| b74be2577f | |||
| 8bf1c9a23a | |||
| fec3fdf7ef | |||
| 242188b7b1 | |||
| 199ef9f8ea | |||
| 46003e85bf | |||
| d8e2030d47 | |||
| 9925ac6a2d | |||
| caace61ba1 | |||
| e7b600e318 | |||
| 4f869b2b76 | |||
| d9ed2c425a | |||
| e273b9851e | |||
| 1ffc319831 | |||
| 97fa44dc82 | |||
| 133f14c0af | |||
| e64d72299e | |||
| 2f2110a32c | |||
| a7d56e3554 | |||
| a9d9a968ed | |||
| a690b62391 | |||
| 30fd2bbe19 | |||
| 0e3ed28950 | |||
| 58fef1d703 | |||
| dfa0b5b4fc | |||
| da6a96ed33 | |||
| a0cf753b2e | |||
| 05817187fe | |||
| c0073b3d47 | |||
| e6a5d5784c | |||
| cf4fe41957 | |||
| c289b5ff9f | |||
| 92c309f2e1 | |||
| 39d725f4d3 | |||
| 4ebefe647a | |||
| b975711429 | |||
| 96c7702526 | |||
| c5d040bbea | |||
| ac6539ed03 | |||
| a87d961ebc | |||
| f7865527af | |||
| c3f54fcddd | |||
| 39d362aeff | |||
| 18fc4c113b | |||
| d4ae8a8d34 | |||
| 8117a13dd6 | |||
| bee0c564cf | |||
| 72adf2458b | |||
| f08fe02a1b | |||
| 39a1c13635 | |||
| 91a3f0a3fe | |||
| d9b19e5970 | |||
| cf8d01bc5a | |||
| 35976da7a0 | |||
| e8197404d0 | |||
| cb3e5c35b0 |
@@ -240,6 +240,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: acpx":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -3,6 +3,8 @@ name: Auto response
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
@@ -42,6 +44,7 @@ jobs:
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
commentTriggers: ["testflight"],
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
@@ -55,11 +58,76 @@ jobs:
|
||||
close: true,
|
||||
lock: true,
|
||||
lockReason: "off-topic",
|
||||
commentTriggers: ["moltbook"],
|
||||
message:
|
||||
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
|
||||
},
|
||||
];
|
||||
|
||||
const maintainerTeam = "maintainer";
|
||||
const pingWarningMessage =
|
||||
"Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
|
||||
const mentionRegex = /@([A-Za-z0-9-]+)/g;
|
||||
const maintainerCache = new Map();
|
||||
const normalizeLogin = (login) => login.toLowerCase();
|
||||
|
||||
const isMaintainer = async (login) => {
|
||||
if (!login) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeLogin(login);
|
||||
if (maintainerCache.has(normalized)) {
|
||||
return maintainerCache.get(normalized);
|
||||
}
|
||||
let isMember = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: maintainerTeam,
|
||||
username: normalized,
|
||||
});
|
||||
isMember = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
maintainerCache.set(normalized, isMember);
|
||||
return isMember;
|
||||
};
|
||||
|
||||
const countMaintainerMentions = async (body, authorLogin) => {
|
||||
if (!body) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
|
||||
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const haystack = body.toLowerCase();
|
||||
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
|
||||
if (haystack.includes(teamMention)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
const mentions = new Set();
|
||||
for (const match of body.matchAll(mentionRegex)) {
|
||||
mentions.add(normalizeLogin(match[1]));
|
||||
}
|
||||
if (normalizedAuthor) {
|
||||
mentions.delete(normalizedAuthor);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const login of mentions) {
|
||||
if (await isMaintainer(login)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
@@ -72,6 +140,63 @@ jobs:
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const comment = context.payload.comment;
|
||||
if (comment) {
|
||||
const authorLogin = comment.user?.login ?? "";
|
||||
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentBody = comment.body ?? "";
|
||||
const responses = [];
|
||||
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
|
||||
if (mentionCount >= 3) {
|
||||
responses.push(pingWarningMessage);
|
||||
}
|
||||
|
||||
const commentHaystack = commentBody.toLowerCase();
|
||||
const commentRule = rules.find((item) =>
|
||||
(item.commentTriggers ?? []).some((trigger) =>
|
||||
commentHaystack.includes(trigger),
|
||||
),
|
||||
);
|
||||
if (commentRule) {
|
||||
responses.push(commentRule.message);
|
||||
}
|
||||
|
||||
if (responses.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
body: responses.join("\n\n"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue) {
|
||||
const action = context.payload.action;
|
||||
if (action === "opened" || action === "edited") {
|
||||
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
const authorLogin = issue.user?.login ?? "";
|
||||
const mentionCount = await countMaintainerMentions(
|
||||
issueText,
|
||||
authorLogin,
|
||||
);
|
||||
if (mentionCount >= 3) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: pingWarningMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
@@ -94,7 +219,6 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
@@ -136,7 +260,6 @@ jobs:
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -418,12 +418,23 @@ jobs:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -495,6 +506,12 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: matrix.task == 'test'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
@@ -512,7 +529,7 @@ jobs:
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Repository Guidelines
|
||||
|
||||
- Repo: https://github.com/openclaw/openclaw
|
||||
- In chat replies, file references must be repo-root relative only (example: `extensions/bluebubbles/src/channel.ts:80`); never absolute paths or `~/...`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
|
||||
+110
-14
@@ -2,6 +2,98 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.26 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- Highlight: External Secrets Management introduces a full `openclaw secrets` workflow (`audit`, `configure`, `apply`, `reload`) with runtime snapshot activation, strict `secrets apply` target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.
|
||||
- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
|
||||
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
|
||||
- Codex/WebSocket transport: make `openai-codex` WebSocket-first by default (`transport: "auto"` with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.
|
||||
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
|
||||
- Android/Nodes: add Android `device` capability plus `device.status` and `device.info` node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.
|
||||
- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
|
||||
- Docs/Contributing: add Nimrod Gutman to the maintainer roster in `CONTRIBUTING.md`. (#27840) Thanks @ngutman.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram/DM allowlist runtime inheritance: enforce `dmPolicy: "allowlist"` `allowFrom` requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align `openclaw doctor` checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.
|
||||
- Delivery queue/recovery backoff: prevent retry starvation by persisting `lastAttemptAt` on failed sends and deferring recovery retries until each entry's `lastAttemptAt + backoff` window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.
|
||||
- Google Chat/Lifecycle: keep Google Chat `startAccount` pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.
|
||||
- Temp dirs/Linux umask: force `0700` permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so `umask 0002` installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.
|
||||
- Nextcloud Talk/Lifecycle: keep `startAccount` pending until abort and stop the webhook monitor on shutdown, preventing `EADDRINUSE` restart loops when the gateway manages account lifecycle. (#27897)
|
||||
- Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.
|
||||
- Queue/Drain/Cron reliability: harden lane draining with guaranteed `draining` flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add `/stop` queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron `agentTurn` outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)
|
||||
- Typing/Main reply pipeline: always mark dispatch idle in `agent-runner` finalization so typing cleanup runs even when dispatcher `onIdle` does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.
|
||||
- Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.
|
||||
- Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.
|
||||
- Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded `sendChatAction` retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.
|
||||
- Telegram/Webhook startup: clarify webhook config guidance, allow `channels.telegram.webhookPort: 0` for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.
|
||||
- Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `…` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)
|
||||
- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)
|
||||
- Browser/Fill relay + CLI parity: accept `act.fill` fields without explicit `type` by defaulting missing/empty `type` to `text` in both browser relay route parsing and `openclaw browser fill` CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.
|
||||
- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.
|
||||
- Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single `mac-*` candidate is selected, default to the first connected candidate instead of failing with `node required` for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.
|
||||
- TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)
|
||||
- Hooks/Internal `message:sent`: forward `sessionKey` on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal `message:sent` hooks consistently dispatch with session context, including `openclaw agent --deliver` runs resumed via `--session-id` (without explicit `--session-key`). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.
|
||||
- Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)
|
||||
- BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.
|
||||
- Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change `openclaw onboard --reset` default scope to `config+creds+sessions` (workspace deletion now requires `--reset-scope full`). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.
|
||||
- NO_REPLY suppression: suppress `NO_REPLY` before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)
|
||||
- Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (`BodyForAgent`) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.
|
||||
- Auto-reply/Streaming: suppress only exact `NO_REPLY` final replies while still filtering streaming partial sentinel fragments (`NO_`, `NO_RE`, `HEARTBEAT_...`) so substantive replies ending with `NO_REPLY` are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
|
||||
- Auto-reply/Inbound metadata: add a readable `timestamp` field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.
|
||||
- Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding `triggerTyping()` with `runComplete`, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.
|
||||
- Typing/Dispatch idle: force typing cleanup when `markDispatchIdle` never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)
|
||||
- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.
|
||||
- Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673.
|
||||
- Browser/Extension relay CORS: handle `/json*` `OPTIONS` preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)
|
||||
- Browser/Extension relay auth: allow `?token=` query-param auth on relay `/json*` endpoints (consistent with relay WebSocket auth) so curl/devtools-style `/json/version` and `/json/list` probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)
|
||||
- Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.
|
||||
- Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.
|
||||
- Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted `%` paths return `400` instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.
|
||||
- Feishu/Inbound message metadata: include inbound `message_id` in `BodyForAgent` on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.
|
||||
- Feishu/Doc tools: route `feishu_doc` and `feishu_app_scopes` through the active agent account context (with explicit `accountId` override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.
|
||||
- LINE/Inline directives auth: gate directive parsing (`/model`, `/think`, `/verbose`, `/reasoning`, `/queue`) on resolved authorization (`command.isAuthorizedSender`) so `commands.allowFrom`-authorized LINE senders are not silently stripped when raw `CommandAuthorized` is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)
|
||||
- Onboarding/Gateway: seed default Control UI `allowedOrigins` for non-loopback binds during onboarding (`localhost`/`127.0.0.1` plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.
|
||||
- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.
|
||||
- CLI/Gateway `--force` in non-root Docker: recover from `lsof` permission failures (`EACCES`/`EPERM`) by falling back to `fuser` kill + probe-based port checks, so `openclaw gateway --force` works for default container `node` user flows. (#27941)
|
||||
- Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.
|
||||
- Sessions cleanup/Doctor: add `openclaw sessions cleanup --fix-missing` to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)
|
||||
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
|
||||
- CLI/Gateway status: force local `gateway status` probe host to `127.0.0.1` for `bind=lan` so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.
|
||||
- CLI/Gateway auth: align `gateway run --auth` parsing/help text with supported gateway auth modes by accepting `none` and `trusted-proxy` (in addition to `token`/`password`) for CLI overrides. (#27469) thanks @s1korrrr.
|
||||
- CLI/Daemon status TLS probe: use `wss://` and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so `openclaw daemon status` works with `gateway.bind=lan` + `gateway.tls.enabled=true`. (#24234) thanks @liuy.
|
||||
- Podman/Default bind: change `run-openclaw-podman.sh` default gateway bind from `lan` to `loopback` and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.
|
||||
- Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent `KeepAlive=true` semantics, and harden restart sequencing to `print -> bootout -> wait old pid exit -> bootstrap -> kickstart`. (#27276) thanks @frankekn.
|
||||
- Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before `/restart` launchctl/systemctl triggers, and set LaunchAgent `ThrottleInterval=60` to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)
|
||||
- Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)
|
||||
- Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker.
|
||||
- Models/Profile suffix parsing: centralize trailing `@profile` parsing and only treat `@` as a profile separator when it appears after the final `/`, preserving model IDs like `openai/@cf/...` and `openrouter/@preset/...` across `/model` directive parsing and allowlist model resolution, with regression coverage.
|
||||
- Models/OpenAI Codex config schema parity: accept `openai-codex-responses` in the config model API schema and TypeScript `ModelApi` union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.
|
||||
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
|
||||
- Azure OpenAI Responses: force `store=true` for `azure-openai-responses` direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)
|
||||
- Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
|
||||
- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Security/Node exec approvals hardening: freeze immutable approval-time execution plans (`argv`/`cwd`/`agentId`/`sessionKey`) via `system.run.prepare`, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
|
||||
- Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting and @gumadeiras for implementation.
|
||||
- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)
|
||||
- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)
|
||||
- Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng.
|
||||
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
|
||||
- Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)
|
||||
- Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)
|
||||
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
|
||||
- iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.
|
||||
- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
@@ -48,29 +140,31 @@ Docs: https://docs.openclaw.ai
|
||||
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
|
||||
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
|
||||
- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.25`). Thanks @luz-oasis for reporting.
|
||||
- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.25`). Thanks @zdi-disclosures for reporting.
|
||||
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.26`). Thanks @luz-oasis for reporting.
|
||||
- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.26`). Thanks @zdi-disclosures for reporting.
|
||||
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
|
||||
- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
|
||||
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.25`). Thanks @tdjackey for reporting.
|
||||
- Security/DM-group allowlist boundaries: keep DM pairing-store approvals DM-only by removing pairing-store inheritance from group sender authorization in LINE and Mattermost message preflight, and by centralizing shared DM/group allowlist composition so group checks never include pairing-store entries. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack interactions: enforce channel/DM authorization and modal actor binding (`private_metadata.userId`) before enqueueing `block_action`/`view_submission`/`view_closed` system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Nextcloud Talk: drop replayed signed webhook events with persistent per-account replay dedupe across restarts, and reject unexpected webhook backend origins when account base URL is configured. Thanks @aristorechina for reporting.
|
||||
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
|
||||
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
|
||||
- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
|
||||
- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
|
||||
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.25`). Thanks @zpbrent for reporting.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
|
||||
## 2026.2.24
|
||||
@@ -234,6 +328,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope.
|
||||
- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese.
|
||||
- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm.
|
||||
- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc.
|
||||
- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc.
|
||||
- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc.
|
||||
- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
|
||||
- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
|
||||
|
||||
@@ -32,6 +32,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Mariano Belinky** - iOS app, Security
|
||||
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
|
||||
|
||||
- **Nimrod Gutman** - iOS app, macOS app and crustacean features
|
||||
- GitHub: [@ngutman](https://github.com/ngutman) · X: [@theguti](https://x.com/theguti)
|
||||
|
||||
- **Vincent Koc** - Agents, Telemetry, Hooks, Security
|
||||
- GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc)
|
||||
|
||||
@@ -50,6 +53,12 @@ Welcome to the lobster tank! 🦞
|
||||
- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams
|
||||
- GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz)
|
||||
|
||||
- **Josh Avant** - Core, CLI, Gateway, Security, Agents
|
||||
- GitHub: [@joshavant](https://github.com/joshavant) · X: [@joshavant](https://x.com/joshavant)
|
||||
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
+3
-1
@@ -23,7 +23,9 @@ COPY --chown=node:node patches ./patches
|
||||
COPY --chown=node:node scripts ./scripts
|
||||
|
||||
USER node
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
|
||||
@@ -41,6 +41,7 @@ For fastest triage, include all of the following:
|
||||
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
|
||||
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
|
||||
- Scope check explaining why the report is **not** covered by the Out of Scope section below.
|
||||
- For command-risk/parity reports (for example obfuscation detection differences), a concrete boundary-bypass path is required (auth/approval/allowlist/sandbox). Parity-only findings are treated as hardening, not vulnerabilities.
|
||||
|
||||
Reports that miss these requirements may be closed as `invalid` or `no-action`.
|
||||
|
||||
@@ -53,10 +54,12 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
|
||||
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
|
||||
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
|
||||
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
|
||||
|
||||
### Duplicate Report Handling
|
||||
@@ -113,8 +116,10 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||
- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses.
|
||||
- Reports whose only claim is heuristic/parity drift in command-risk detection (for example obfuscation-pattern checks) across exec surfaces, without a demonstrated trust-boundary bypass. These are hardening-only findings and are not vulnerabilities; triage may close them as `invalid`/`no-action` or track them separately as low/informational hardening.
|
||||
- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact
|
||||
- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass.
|
||||
- Reports whose only claim is that a platform-provided upload destination URL is untrusted (for example Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl`) without proving attacker control in an authenticated production flow.
|
||||
|
||||
## Deployment Assumptions
|
||||
|
||||
@@ -150,6 +155,7 @@ OpenClaw separates routing from execution, but both remain inside the same opera
|
||||
- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway.
|
||||
- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node.
|
||||
- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary.
|
||||
- Differences in command-risk warning heuristics between exec surfaces (`gateway`, `node`, `sandbox`) do not, by themselves, constitute a security-boundary bypass.
|
||||
- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary.
|
||||
|
||||
## Workspace Memory Trust Boundary
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602250
|
||||
versionName = "2026.2.25"
|
||||
versionCode = 202602260
|
||||
versionName = "2026.2.26"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -146,6 +146,7 @@ dependencies {
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -92,6 +92,14 @@ class NodeRuntime(context: Context) {
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
@@ -123,6 +131,8 @@ class NodeRuntime(context: Context) {
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
@@ -131,6 +141,8 @@ class NodeRuntime(context: Context) {
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
internal object DeviceAuthPayload {
|
||||
fun buildV3(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
platform: String?,
|
||||
deviceFamily: String?,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val platformNorm = normalizeMetadataField(platform)
|
||||
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
|
||||
return listOf(
|
||||
"v3",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
nonce,
|
||||
platformNorm,
|
||||
deviceFamilyNorm,
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
internal fun normalizeMetadataField(value: String?): String {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
|
||||
// lowercase ASCII A-Z only for auth payload metadata fields.
|
||||
val out = StringBuilder(trimmed.length)
|
||||
for (ch in trimmed) {
|
||||
if (ch in 'A'..'Z') {
|
||||
out.append((ch.code + 32).toChar())
|
||||
} else {
|
||||
out.append(ch)
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,18 @@ package ai.openclaw.android.gateway
|
||||
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadToken(deviceId: String, role: String): String?
|
||||
fun saveToken(deviceId: String, role: String, token: String)
|
||||
}
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ data class GatewayConnectOptions(
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
@@ -200,9 +200,7 @@ class GatewaySession(
|
||||
suspend fun connect() {
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val httpScheme = if (tls != null) "https" else "http"
|
||||
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).header("Origin", origin).build()
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
@@ -374,7 +372,7 @@ class GatewaySession(
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
buildDeviceAuthPayload(
|
||||
DeviceAuthPayload.buildV3(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
@@ -383,6 +381,8 @@ class GatewaySession(
|
||||
signedAtMs = signedAtMs,
|
||||
token = if (authToken.isNotEmpty()) authToken else null,
|
||||
nonce = connectNonce,
|
||||
platform = client.platform,
|
||||
deviceFamily = client.deviceFamily,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
@@ -535,16 +535,8 @@ class GatewaySession(
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
|
||||
return InvokeResult.error(code = parsed.code, message = parsed.message)
|
||||
}
|
||||
|
||||
private fun failPending() {
|
||||
@@ -592,33 +584,6 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDeviceAuthPayload(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
val parts =
|
||||
mutableListOf(
|
||||
"v2",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
nonce,
|
||||
)
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(
|
||||
raw: String?,
|
||||
endpoint: GatewayEndpoint,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
data class ParsedInvokeError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val hadExplicitCode: Boolean,
|
||||
) {
|
||||
val prefixedMessage: String
|
||||
get() = "$code: $message"
|
||||
}
|
||||
|
||||
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
|
||||
}
|
||||
|
||||
val parts = trimmed.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return ParsedInvokeError(
|
||||
code = code,
|
||||
message = rest.ifEmpty { trimmed },
|
||||
hadExplicitCode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
|
||||
}
|
||||
|
||||
fun parseInvokeErrorFromThrowable(
|
||||
err: Throwable,
|
||||
fallbackMessage: String = "error",
|
||||
): ParsedInvokeError {
|
||||
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
|
||||
return parseInvokeErrorMessage(raw)
|
||||
}
|
||||
@@ -81,8 +81,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 800
|
||||
val quality = (parseQuality(paramsJson) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 1600
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
|
||||
@@ -7,12 +7,6 @@ import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
@@ -80,37 +74,18 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
add(OpenClawCanvasCommand.Hide.rawValue)
|
||||
add(OpenClawCanvasCommand.Navigate.rawValue)
|
||||
add(OpenClawCanvasCommand.Eval.rawValue)
|
||||
add(OpenClawCanvasCommand.Snapshot.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Push.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Reset.rawValue)
|
||||
add(OpenClawScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled()) {
|
||||
add(OpenClawCameraCommand.Snap.rawValue)
|
||||
add(OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (smsAvailable()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
add("debug.logs")
|
||||
add("debug.ed25519")
|
||||
}
|
||||
add("app.update")
|
||||
}
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
add(OpenClawCapability.Device.rawValue)
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.util.Locale
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
) {
|
||||
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(statusPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(infoPayloadJson())
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val batteryIntent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val batteryStatus =
|
||||
batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val batteryLevel = batteryLevelFraction(batteryIntent)
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val storage = StatFs(Environment.getDataDirectory().absolutePath)
|
||||
val totalBytes = storage.totalBytes
|
||||
val freeBytes = storage.availableBytes
|
||||
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
|
||||
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
|
||||
val activeNetwork = connectivity?.activeNetwork
|
||||
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
|
||||
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
|
||||
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
batteryLevel?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(batteryStatus)))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"thermal",
|
||||
buildJsonObject {
|
||||
put("state", JsonPrimitive(mapThermalState(powerManager)))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"storage",
|
||||
buildJsonObject {
|
||||
put("totalBytes", JsonPrimitive(totalBytes))
|
||||
put("freeBytes", JsonPrimitive(freeBytes))
|
||||
put("usedBytes", JsonPrimitive(usedBytes))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"network",
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive(mapNetworkStatus(caps)))
|
||||
put(
|
||||
"isExpensive",
|
||||
JsonPrimitive(
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)?.not() ?: false,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"isConstrained",
|
||||
JsonPrimitive(
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)?.not() ?: false,
|
||||
),
|
||||
)
|
||||
put("interfaces", networkInterfacesJson(caps))
|
||||
},
|
||||
)
|
||||
put("uptimeSeconds", JsonPrimitive(uptimeSeconds))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun infoPayloadJson(): String {
|
||||
val model = Build.MODEL?.trim().orEmpty()
|
||||
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
|
||||
val modelIdentifier = Build.DEVICE?.trim().orEmpty()
|
||||
val systemVersion = Build.VERSION.RELEASE?.trim().orEmpty()
|
||||
val locale = Locale.getDefault().toLanguageTag().trim()
|
||||
val appVersion = BuildConfig.VERSION_NAME.trim()
|
||||
val appBuild = BuildConfig.VERSION_CODE.toString()
|
||||
|
||||
return buildJsonObject {
|
||||
put("deviceName", JsonPrimitive(model.ifEmpty { "Android" }))
|
||||
put("modelIdentifier", JsonPrimitive(modelIdentifier.ifEmpty { listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ") }))
|
||||
put("systemName", JsonPrimitive("Android"))
|
||||
put("systemVersion", JsonPrimitive(systemVersion.ifEmpty { Build.VERSION.SDK_INT.toString() }))
|
||||
put("appVersion", JsonPrimitive(appVersion.ifEmpty { "dev" }))
|
||||
put("appBuild", JsonPrimitive(appBuild.ifEmpty { "0" }))
|
||||
put("locale", JsonPrimitive(locale.ifEmpty { Locale.getDefault().toString() }))
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun batteryLevelFraction(intent: Intent?): Double? {
|
||||
val rawLevel = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
|
||||
val rawScale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
|
||||
if (rawLevel < 0 || rawScale <= 0) return null
|
||||
return rawLevel.toDouble() / rawScale.toDouble()
|
||||
}
|
||||
|
||||
private fun mapBatteryState(status: Int): String {
|
||||
return when (status) {
|
||||
BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
|
||||
BatteryManager.BATTERY_STATUS_FULL -> "full"
|
||||
BatteryManager.BATTERY_STATUS_DISCHARGING, BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapThermalState(powerManager: PowerManager?): String {
|
||||
val thermal = powerManager?.currentThermalStatus ?: return "nominal"
|
||||
return when (thermal) {
|
||||
PowerManager.THERMAL_STATUS_NONE, PowerManager.THERMAL_STATUS_LIGHT -> "nominal"
|
||||
PowerManager.THERMAL_STATUS_MODERATE -> "fair"
|
||||
PowerManager.THERMAL_STATUS_SEVERE -> "serious"
|
||||
PowerManager.THERMAL_STATUS_CRITICAL,
|
||||
PowerManager.THERMAL_STATUS_EMERGENCY,
|
||||
PowerManager.THERMAL_STATUS_SHUTDOWN -> "critical"
|
||||
else -> "nominal"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapNetworkStatus(caps: NetworkCapabilities?): String {
|
||||
if (caps == null) return "unsatisfied"
|
||||
return when {
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
|
||||
else -> "unsatisfied"
|
||||
}
|
||||
}
|
||||
|
||||
private fun networkInterfacesJson(caps: NetworkCapabilities?) =
|
||||
buildJsonArray {
|
||||
if (caps == null) return@buildJsonArray
|
||||
var hasKnownTransport = false
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
hasKnownTransport = true
|
||||
add(JsonPrimitive("wifi"))
|
||||
}
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
hasKnownTransport = true
|
||||
add(JsonPrimitive("cellular"))
|
||||
}
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
hasKnownTransport = true
|
||||
add(JsonPrimitive("wired"))
|
||||
}
|
||||
if (!hasKnownTransport) add(JsonPrimitive("other"))
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
|
||||
}
|
||||
|
||||
data class DeviceNotificationEntry(
|
||||
val key: String,
|
||||
val packageName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
val subText: String?,
|
||||
val category: String?,
|
||||
val channelId: String?,
|
||||
val postTimeMs: Long,
|
||||
val isOngoing: Boolean,
|
||||
val isClearable: Boolean,
|
||||
)
|
||||
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
|
||||
|
||||
fun replace(entries: List<DeviceNotificationEntry>) {
|
||||
synchronized(lock) {
|
||||
byKey.clear()
|
||||
for (entry in entries) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsert(entry: DeviceNotificationEntry) {
|
||||
synchronized(lock) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
synchronized(lock) {
|
||||
byKey.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun setConnected(value: Boolean) {
|
||||
synchronized(lock) {
|
||||
connected = value
|
||||
if (!value) {
|
||||
byKey.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(enabled: Boolean): DeviceNotificationSnapshot {
|
||||
val (isConnected, entries) =
|
||||
synchronized(lock) {
|
||||
connected to byKey.values.sortedByDescending { it.postTimeMs }
|
||||
}
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = enabled,
|
||||
connected = isConnected,
|
||||
notifications = entries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
DeviceNotificationStore.setConnected(true)
|
||||
refreshActiveNotifications()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
DeviceNotificationStore.setConnected(false)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
val key = sbn?.key ?: return
|
||||
DeviceNotificationStore.remove(key)
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
val entries =
|
||||
runCatching {
|
||||
activeNotifications
|
||||
?.mapNotNull { it.toEntry() }
|
||||
?: emptyList()
|
||||
}.getOrElse { emptyList() }
|
||||
DeviceNotificationStore.replace(entries)
|
||||
}
|
||||
|
||||
private fun StatusBarNotification.toEntry(): DeviceNotificationEntry {
|
||||
val extras = notification.extras
|
||||
val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime"
|
||||
val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
|
||||
val body =
|
||||
sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT))
|
||||
?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT))
|
||||
val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
|
||||
return DeviceNotificationEntry(
|
||||
key = keyValue,
|
||||
packageName = packageName,
|
||||
title = title,
|
||||
text = body,
|
||||
subText = subText,
|
||||
category = notification.category?.trim()?.ifEmpty { null },
|
||||
channelId = notification.channelId?.trim()?.ifEmpty { null },
|
||||
postTimeMs = postTime,
|
||||
isOngoing = isOngoing,
|
||||
isClearable = isClearable,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
}
|
||||
|
||||
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
|
||||
return DeviceNotificationStore.snapshot(enabled = enabled)
|
||||
}
|
||||
|
||||
fun requestServiceRebind(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always,
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Present.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Hide.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Navigate.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Eval.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Snapshot.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Push.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.PushJSONL.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Reset.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Clip.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawLocationCommand.Get.rawValue,
|
||||
availability = InvokeCommandAvailability.LocationEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Status.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Info.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.ed25519",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(name = "app.update"),
|
||||
)
|
||||
|
||||
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
fun advertisedCommands(
|
||||
cameraEnabled: Boolean,
|
||||
locationEnabled: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
debugBuild: Boolean,
|
||||
): List<String> {
|
||||
return all
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> smsAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> debugBuild
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
@@ -12,6 +14,8 @@ class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
private val cameraHandler: CameraHandler,
|
||||
private val locationHandler: LocationHandler,
|
||||
private val deviceHandler: DeviceHandler,
|
||||
private val notificationsHandler: NotificationsHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
@@ -20,40 +24,25 @@ class InvokeDispatcher(
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
// Check foreground requirement for canvas/camera/screen commands
|
||||
if (
|
||||
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
val spec =
|
||||
InvokeCommandRegistry.find(command)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check camera enabled
|
||||
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
|
||||
if (spec.requiresForeground && !isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
// Check location enabled
|
||||
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
availabilityError(spec.availability)?.let { return it }
|
||||
|
||||
return when (command) {
|
||||
// Canvas commands
|
||||
@@ -75,53 +64,33 @@ class InvokeDispatcher(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
withCanvasAvailable {
|
||||
val result = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
}
|
||||
OpenClawCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
withCanvasAvailable {
|
||||
val base64 =
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
}
|
||||
|
||||
// A2UI commands
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue ->
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
}
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
@@ -132,22 +101,14 @@ class InvokeDispatcher(
|
||||
message = err.message ?: "invalid A2UI payload"
|
||||
)
|
||||
}
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
}
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
@@ -157,6 +118,13 @@ class InvokeDispatcher(
|
||||
// Location command
|
||||
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
|
||||
|
||||
// Device commands
|
||||
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
|
||||
@@ -170,11 +138,80 @@ class InvokeDispatcher(
|
||||
// App update
|
||||
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
|
||||
|
||||
else ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
private suspend fun withCanvasAvailable(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
block()
|
||||
} catch (_: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
|
||||
return when (availability) {
|
||||
InvokeCommandAvailability.Always -> null
|
||||
InvokeCommandAvailability.CameraEnabled ->
|
||||
if (cameraEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.LocationEnabled ->
|
||||
if (locationEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -37,14 +38,9 @@ fun parseHexColorArgb(raw: String?): Long? {
|
||||
}
|
||||
|
||||
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
return code to "$code: $message"
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
|
||||
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
|
||||
return parsed.code to message
|
||||
}
|
||||
|
||||
fun normalizeMainKey(raw: String?): String? {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
fun requestServiceRebind(context: Context)
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
|
||||
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
if (!enabled) {
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
)
|
||||
}
|
||||
return DeviceNotificationListenerService.snapshot(context, enabled = true)
|
||||
}
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val stateProvider: NotificationsStateProvider,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
return buildJsonObject {
|
||||
put("enabled", JsonPrimitive(snapshot.enabled))
|
||||
put("connected", JsonPrimitive(snapshot.connected))
|
||||
put("count", JsonPrimitive(snapshot.notifications.size))
|
||||
put(
|
||||
"notifications",
|
||||
JsonArray(
|
||||
snapshot.notifications.map { entry ->
|
||||
buildJsonObject {
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
stateProvider: NotificationsStateProvider,
|
||||
): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider)
|
||||
}
|
||||
}
|
||||
+20
@@ -7,6 +7,7 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
Device("device"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -69,3 +70,22 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
Status("device.status"),
|
||||
Info("device.info"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "device."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class DeviceAuthPayloadTest {
|
||||
@Test
|
||||
fun buildV3_matchesCanonicalVector() {
|
||||
val payload =
|
||||
DeviceAuthPayload.buildV3(
|
||||
deviceId = "dev-1",
|
||||
clientId = "openclaw-macos",
|
||||
clientMode = "ui",
|
||||
role = "operator",
|
||||
scopes = listOf("operator.admin", "operator.read"),
|
||||
signedAtMs = 1_700_000_000_000,
|
||||
token = "tok-123",
|
||||
nonce = "nonce-abc",
|
||||
platform = " IOS ",
|
||||
deviceFamily = " iPhone ",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone",
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeMetadataField_asciiOnlyLowercase() {
|
||||
assertEquals("İos", DeviceAuthPayload.normalizeMetadataField(" İOS "))
|
||||
assertEquals("mac", DeviceAuthPayload.normalizeMetadataField(" MAC "))
|
||||
assertEquals("", DeviceAuthPayload.normalizeMetadataField(null))
|
||||
}
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeErrorParserTest {
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code)
|
||||
assertEquals("grant Camera permission", parsed.message)
|
||||
assertTrue(parsed.hadExplicitCode)
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("IllegalStateException: boom")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("IllegalStateException: boom", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() {
|
||||
val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("fallback", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DeviceHandlerTest {
|
||||
@Test
|
||||
fun handleDeviceInfo_returnsStablePayload() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceInfo(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
assertEquals("Android", payload.getValue("systemName").jsonPrimitive.content)
|
||||
assertTrue(payload.getValue("deviceName").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("modelIdentifier").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("systemVersion").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("appVersion").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("appBuild").jsonPrimitive.content.isNotBlank())
|
||||
assertTrue(payload.getValue("locale").jsonPrimitive.content.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceStatus_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceStatus(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val battery = payload.getValue("battery").jsonObject
|
||||
val storage = payload.getValue("storage").jsonObject
|
||||
val thermal = payload.getValue("thermal").jsonObject
|
||||
val network = payload.getValue("network").jsonObject
|
||||
|
||||
val state = battery.getValue("state").jsonPrimitive.content
|
||||
assertTrue(state in setOf("unknown", "unplugged", "charging", "full"))
|
||||
battery["level"]?.jsonPrimitive?.double?.let { level ->
|
||||
assertTrue(level in 0.0..1.0)
|
||||
}
|
||||
battery.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
|
||||
|
||||
val totalBytes = storage.getValue("totalBytes").jsonPrimitive.content.toLong()
|
||||
val freeBytes = storage.getValue("freeBytes").jsonPrimitive.content.toLong()
|
||||
val usedBytes = storage.getValue("usedBytes").jsonPrimitive.content.toLong()
|
||||
assertTrue(totalBytes >= 0L)
|
||||
assertTrue(freeBytes >= 0L)
|
||||
assertTrue(usedBytes >= 0L)
|
||||
assertEquals((totalBytes - freeBytes).coerceAtLeast(0L), usedBytes)
|
||||
|
||||
val thermalState = thermal.getValue("state").jsonPrimitive.content
|
||||
assertTrue(thermalState in setOf("nominal", "fair", "serious", "critical"))
|
||||
|
||||
val networkStatus = network.getValue("status").jsonPrimitive.content
|
||||
assertTrue(networkStatus in setOf("satisfied", "unsatisfied", "requiresConnection"))
|
||||
val interfaces = network.getValue("interfaces").jsonArray.map { it.jsonPrimitive.content }
|
||||
assertTrue(interfaces.all { it in setOf("wifi", "cellular", "wired", "other") })
|
||||
|
||||
assertTrue(payload.getValue("uptimeSeconds").jsonPrimitive.double >= 0.0)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
val jsonString = payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(jsonString).jsonObject
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
debugBuild = false,
|
||||
)
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
debugBuild = true,
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NotificationsHandlerTest {
|
||||
@Test
|
||||
fun notificationsListReturnsStatusPayloadWhenDisabled() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertFalse(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(0, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListRequestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n1")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(1, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListDoesNotRequestRebindWhenConnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertTrue(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextReturnsNullForBlankInput() {
|
||||
assertNull(sanitizeNotificationText(null))
|
||||
assertNull(sanitizeNotificationText(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextTrimsAndTruncates() {
|
||||
val value = " ${"x".repeat(600)} "
|
||||
val sanitized = sanitizeNotificationText(value)
|
||||
|
||||
assertEquals(512, sanitized?.length)
|
||||
assertTrue((sanitized ?: "").all { it == 'x' })
|
||||
}
|
||||
|
||||
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
|
||||
val payloadJson = result.payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(payloadJson).jsonObject
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun sampleEntry(key: String): DeviceNotificationEntry =
|
||||
DeviceNotificationEntry(
|
||||
key = key,
|
||||
packageName = "com.example.app",
|
||||
title = "Title",
|
||||
text = "Text",
|
||||
subText = null,
|
||||
category = null,
|
||||
channelId = null,
|
||||
postTimeMs = 123L,
|
||||
isOngoing = false,
|
||||
isClearable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeNotificationsStateProvider(
|
||||
private val snapshot: DeviceNotificationSnapshot,
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
rebindRequests += 1
|
||||
}
|
||||
}
|
||||
+14
@@ -26,10 +26,24 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("camera", OpenClawCapability.Camera.rawValue)
|
||||
assertEquals("screen", OpenClawCapability.Screen.rawValue)
|
||||
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
|
||||
assertEquals("location", OpenClawCapability.Location.rawValue)
|
||||
assertEquals("sms", OpenClawCapability.Sms.rawValue)
|
||||
assertEquals("device", OpenClawCapability.Device.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun screenCommandsUseStableStrings() {
|
||||
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsCommandsUseStableStrings() {
|
||||
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceCommandsUseStableStrings() {
|
||||
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.25</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260225</string>
|
||||
<string>20260226</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -22,7 +22,6 @@ struct SettingsTab: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@@ -326,10 +325,6 @@ struct SettingsTab: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.featureToggle(
|
||||
"Voice Directive Hint",
|
||||
isOn: self.$talkVoiceDirectiveHintEnabled,
|
||||
help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
|
||||
self.featureToggle(
|
||||
"Show Talk Button",
|
||||
isOn: self.$talkButtonEnabled,
|
||||
|
||||
@@ -850,11 +850,10 @@ final class TalkModeManager: NSObject {
|
||||
private func buildPrompt(transcript: String) -> String {
|
||||
let interrupted = self.lastInterruptedAtSeconds
|
||||
self.lastInterruptedAtSeconds = nil
|
||||
let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true
|
||||
return TalkPromptBuilder.build(
|
||||
transcript: transcript,
|
||||
interruptedAtSeconds: interrupted,
|
||||
includeVoiceDirectiveHint: includeVoiceDirectiveHint)
|
||||
includeVoiceDirectiveHint: false)
|
||||
}
|
||||
|
||||
private enum ChatCompletionState: CustomStringConvertible {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.25</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260225</string>
|
||||
<string>20260226</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||
<key>WKWatchKitApp</key>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.23</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260223</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
+10
-10
@@ -92,8 +92,8 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -146,8 +146,8 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||
@@ -176,8 +176,8 @@ targets:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
@@ -200,8 +200,8 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
@@ -234,5 +234,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.23"
|
||||
CFBundleVersion: "20260223"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
/// Keep in sync with src/infra/host-env-security-policy.json.
|
||||
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
private static let blockedKeys: Set<String> = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"SHELL",
|
||||
"SHELLOPTS",
|
||||
"PS4",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
]
|
||||
|
||||
private static let blockedPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
"BASH_FUNC_",
|
||||
]
|
||||
private static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"ZDOTDIR",
|
||||
]
|
||||
private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys
|
||||
private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes
|
||||
private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys
|
||||
private static let shellWrapperAllowedOverrideKeys: Set<String> = [
|
||||
"TERM",
|
||||
"LANG",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Generated file. Do not edit directly.
|
||||
// Source: src/infra/host-env-security-policy.json
|
||||
// Regenerate: node scripts/generate-host-env-security-policy-swift.mjs --write
|
||||
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSecurityPolicy {
|
||||
static let blockedKeys: Set<String> = [
|
||||
"NODE_OPTIONS",
|
||||
"NODE_PATH",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"BASH_ENV",
|
||||
"ENV",
|
||||
"GIT_EXTERNAL_DIFF",
|
||||
"SHELL",
|
||||
"SHELLOPTS",
|
||||
"PS4",
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE"
|
||||
]
|
||||
|
||||
static let blockedOverrideKeys: Set<String> = [
|
||||
"HOME",
|
||||
"ZDOTDIR"
|
||||
]
|
||||
|
||||
static let blockedPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
"BASH_FUNC_"
|
||||
]
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.25</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602250</string>
|
||||
<string>202602260</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -280,19 +280,17 @@ actor GatewayWizardClient {
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: identity.deviceId,
|
||||
clientId: clientId,
|
||||
clientMode: clientMode,
|
||||
role: role,
|
||||
scopes: scopes,
|
||||
signedAtMs: signedAtMs,
|
||||
token: self.token,
|
||||
nonce: connectNonce,
|
||||
platform: platform,
|
||||
deviceFamily: "Mac")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
|
||||
@@ -2810,6 +2810,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let commandargv: [String]?
|
||||
public let systemrunplanv2: [String: AnyCodable]?
|
||||
public let env: [String: AnyCodable]?
|
||||
public let cwd: AnyCodable?
|
||||
public let nodeid: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
@@ -2818,6 +2820,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let turnsourcechannel: AnyCodable?
|
||||
public let turnsourceto: AnyCodable?
|
||||
public let turnsourceaccountid: AnyCodable?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
@@ -2825,6 +2831,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2833,12 +2841,18 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
turnsourcechannel: AnyCodable?,
|
||||
turnsourceto: AnyCodable?,
|
||||
turnsourceaccountid: AnyCodable?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.commandargv = commandargv
|
||||
self.systemrunplanv2 = systemrunplanv2
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
self.nodeid = nodeid
|
||||
self.host = host
|
||||
@@ -2847,6 +2861,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
@@ -2855,6 +2873,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2863,6 +2883,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
@@ -2976,6 +3000,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
public let publickey: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let devicefamily: String?
|
||||
public let clientid: String?
|
||||
public let clientmode: String?
|
||||
public let role: String?
|
||||
@@ -2992,6 +3017,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -3007,6 +3033,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
self.publickey = publickey
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.devicefamily = devicefamily
|
||||
self.clientid = clientid
|
||||
self.clientmode = clientmode
|
||||
self.role = role
|
||||
@@ -3024,6 +3051,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
public enum GatewayDeviceAuthPayload {
|
||||
public static func buildV3(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
role: String,
|
||||
scopes: [String],
|
||||
signedAtMs: Int,
|
||||
token: String?,
|
||||
nonce: String,
|
||||
platform: String?,
|
||||
deviceFamily: String?) -> String
|
||||
{
|
||||
let scopeString = scopes.joined(separator: ",")
|
||||
let authToken = token ?? ""
|
||||
let normalizedPlatform = normalizeMetadataField(platform)
|
||||
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
|
||||
return [
|
||||
"v3",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
String(signedAtMs),
|
||||
authToken,
|
||||
nonce,
|
||||
normalizedPlatform,
|
||||
normalizedDeviceFamily,
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
static func normalizeMetadataField(_ value: String?) -> String {
|
||||
guard let value else { return "" }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return ""
|
||||
}
|
||||
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
|
||||
// lowercase ASCII A-Z only for auth payload metadata fields.
|
||||
var output = String()
|
||||
output.reserveCapacity(trimmed.count)
|
||||
for scalar in trimmed.unicodeScalars {
|
||||
let codePoint = scalar.value
|
||||
if codePoint >= 65, codePoint <= 90, let lowered = UnicodeScalar(codePoint + 32) {
|
||||
output.unicodeScalars.append(lowered)
|
||||
} else {
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -398,20 +398,18 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity?.deviceId ?? "",
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
authToken ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if includeDeviceIdentity, let identity {
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: identity.deviceId,
|
||||
clientId: clientId,
|
||||
clientMode: clientMode,
|
||||
role: role,
|
||||
scopes: scopes,
|
||||
signedAtMs: signedAtMs,
|
||||
token: authToken,
|
||||
nonce: connectNonce,
|
||||
platform: platform,
|
||||
deviceFamily: InstanceIdentity.deviceFamily)
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
let device: [String: ProtoAnyCodable] = [
|
||||
|
||||
@@ -2810,6 +2810,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let commandargv: [String]?
|
||||
public let systemrunplanv2: [String: AnyCodable]?
|
||||
public let env: [String: AnyCodable]?
|
||||
public let cwd: AnyCodable?
|
||||
public let nodeid: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
@@ -2818,6 +2820,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let turnsourcechannel: AnyCodable?
|
||||
public let turnsourceto: AnyCodable?
|
||||
public let turnsourceaccountid: AnyCodable?
|
||||
public let turnsourcethreadid: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
@@ -2825,6 +2831,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2833,12 +2841,18 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
turnsourcechannel: AnyCodable?,
|
||||
turnsourceto: AnyCodable?,
|
||||
turnsourceaccountid: AnyCodable?,
|
||||
turnsourcethreadid: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?)
|
||||
{
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.commandargv = commandargv
|
||||
self.systemrunplanv2 = systemrunplanv2
|
||||
self.env = env
|
||||
self.cwd = cwd
|
||||
self.nodeid = nodeid
|
||||
self.host = host
|
||||
@@ -2847,6 +2861,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
self.turnsourceto = turnsourceto
|
||||
self.turnsourceaccountid = turnsourceaccountid
|
||||
self.turnsourcethreadid = turnsourcethreadid
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
@@ -2855,6 +2873,8 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2863,6 +2883,10 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
case turnsourceto = "turnSourceTo"
|
||||
case turnsourceaccountid = "turnSourceAccountId"
|
||||
case turnsourcethreadid = "turnSourceThreadId"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
@@ -2976,6 +3000,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
public let publickey: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let devicefamily: String?
|
||||
public let clientid: String?
|
||||
public let clientmode: String?
|
||||
public let role: String?
|
||||
@@ -2992,6 +3017,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -3007,6 +3033,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
self.publickey = publickey
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.devicefamily = devicefamily
|
||||
self.clientid = clientid
|
||||
self.clientmode = clientmode
|
||||
self.role = role
|
||||
@@ -3024,6 +3051,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
@Suite("DeviceAuthPayload")
|
||||
struct DeviceAuthPayloadTests {
|
||||
@Test("builds canonical v3 payload vector")
|
||||
func buildsCanonicalV3PayloadVector() {
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: "dev-1",
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "ui",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
signedAtMs: 1_700_000_000_000,
|
||||
token: "tok-123",
|
||||
nonce: "nonce-abc",
|
||||
platform: " IOS ",
|
||||
deviceFamily: " iPhone ")
|
||||
#expect(
|
||||
payload
|
||||
== "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
|
||||
}
|
||||
|
||||
@Test("normalizes metadata with ASCII-only lowercase")
|
||||
func normalizesMetadataWithAsciiLowercase() {
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos")
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac")
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ const BADGE = {
|
||||
let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
let relayGatewayToken = ''
|
||||
/** @type {string|null} */
|
||||
let relayConnectRequestId = null
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
@@ -143,6 +146,13 @@ async function ensureRelayConnection() {
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
relayWs = ws
|
||||
relayGatewayToken = gatewayToken
|
||||
// Bind message handler before open so an immediate first frame (for example
|
||||
// gateway connect.challenge) cannot be missed.
|
||||
ws.onmessage = (event) => {
|
||||
if (ws !== relayWs) return
|
||||
void whenReady(() => onRelayMessage(String(event.data || '')))
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
|
||||
@@ -162,10 +172,6 @@ async function ensureRelayConnection() {
|
||||
|
||||
// Bind permanent handlers. Guard against stale socket: if this WS was
|
||||
// replaced before its close fires, the handler is a no-op.
|
||||
ws.onmessage = (event) => {
|
||||
if (ws !== relayWs) return
|
||||
void whenReady(() => onRelayMessage(String(event.data || '')))
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (ws !== relayWs) return
|
||||
onRelayClosed('closed')
|
||||
@@ -188,6 +194,8 @@ async function ensureRelayConnection() {
|
||||
// Debugger sessions are kept alive so they survive transient WS drops.
|
||||
function onRelayClosed(reason) {
|
||||
relayWs = null
|
||||
relayGatewayToken = ''
|
||||
relayConnectRequestId = null
|
||||
|
||||
for (const [id, p] of pending.entries()) {
|
||||
pending.delete(id)
|
||||
@@ -308,6 +316,33 @@ function sendToRelay(payload) {
|
||||
ws.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function ensureGatewayHandshakeStarted(payload) {
|
||||
if (relayConnectRequestId) return
|
||||
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
|
||||
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
sendToRelay({
|
||||
type: 'req',
|
||||
id: relayConnectRequestId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'chrome-relay-extension',
|
||||
version: '1.0.0',
|
||||
platform: 'chrome-extension',
|
||||
mode: 'webchat',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read', 'operator.write'],
|
||||
caps: [],
|
||||
commands: [],
|
||||
nonce: nonce || undefined,
|
||||
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function maybeOpenHelpOnce() {
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
|
||||
@@ -349,6 +384,33 @@ async function onRelayMessage(text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.type === 'event' && msg.event === 'connect.challenge') {
|
||||
try {
|
||||
ensureGatewayHandshakeStarted(msg.payload)
|
||||
} catch (err) {
|
||||
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
|
||||
relayConnectRequestId = null
|
||||
const ws = relayWs
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1008, 'gateway connect failed')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
|
||||
relayConnectRequestId = null
|
||||
if (!msg.ok) {
|
||||
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
|
||||
console.warn('gateway connect handshake rejected', String(detail))
|
||||
const ws = relayWs
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1008, 'gateway connect failed')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (msg && msg.method === 'ping') {
|
||||
try {
|
||||
sendToRelay({ method: 'pong' })
|
||||
|
||||
+81
-1
@@ -20,6 +20,78 @@ require_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
read_config_gateway_token() {
|
||||
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$config_path" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
|
||||
gateway = cfg.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
raise SystemExit(0)
|
||||
auth = gateway.get("auth")
|
||||
if not isinstance(auth, dict):
|
||||
raise SystemExit(0)
|
||||
token = auth.get("token")
|
||||
if isinstance(token, str):
|
||||
token = token.strip()
|
||||
if token:
|
||||
print(token)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node - "$config_path" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const configPath = process.argv[2];
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const token = cfg?.gateway?.auth?.token;
|
||||
if (typeof token === "string" && token.trim().length > 0) {
|
||||
process.stdout.write(token.trim());
|
||||
}
|
||||
} catch {
|
||||
// Keep docker-setup resilient when config parsing fails.
|
||||
}
|
||||
NODE
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_control_ui_allowed_origins() {
|
||||
if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local allowed_origin_json
|
||||
local current_allowed_origins
|
||||
allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")"
|
||||
current_allowed_origins="$(
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config get gateway.controlUi.allowedOrigins 2>/dev/null || true
|
||||
)"
|
||||
current_allowed_origins="${current_allowed_origins//$'\r'/}"
|
||||
|
||||
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
|
||||
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null
|
||||
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
|
||||
}
|
||||
|
||||
contains_disallowed_chars() {
|
||||
local value="$1"
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
@@ -97,7 +169,11 @@ export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
|
||||
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
|
||||
|
||||
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
|
||||
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
|
||||
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
||||
else
|
||||
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
|
||||
@@ -273,6 +349,10 @@ echo " - Install Gateway daemon: No"
|
||||
echo ""
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon
|
||||
|
||||
echo ""
|
||||
echo "==> Control UI origin allowlist"
|
||||
ensure_control_ui_allowed_origins
|
||||
|
||||
echo ""
|
||||
echo "==> Provider setup (optional)"
|
||||
echo "WhatsApp (QR):"
|
||||
|
||||
@@ -376,6 +376,12 @@ Example:
|
||||
|
||||
If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode).
|
||||
|
||||
Multi-account precedence:
|
||||
|
||||
- `channels.discord.accounts.default.allowFrom` applies only to the `default` account.
|
||||
- Named accounts inherit `channels.discord.allowFrom` when their own `allowFrom` is unset.
|
||||
- Named accounts do not inherit `channels.discord.accounts.default.allowFrom`.
|
||||
|
||||
DM target format for delivery:
|
||||
|
||||
- `user:<id>`
|
||||
@@ -665,9 +671,10 @@ Default slash command settings:
|
||||
- `session.threadBindings.*` sets global defaults.
|
||||
- `channels.discord.threadBindings.*` overrides Discord behavior.
|
||||
- `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`.
|
||||
- `spawnAcpSessions` must be true to auto-create/bind threads for ACP (`/acp spawn ... --thread ...` or `sessions_spawn({ runtime: "acp", thread: true })`).
|
||||
- If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable.
|
||||
|
||||
See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference).
|
||||
See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ Use these identifiers for delivery and allowlists:
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
serviceAccountFile: "/path/to/service-account.json",
|
||||
// or serviceAccountRef: { source: "file", provider: "filemain", id: "/channels/googlechat/serviceAccount" }
|
||||
audienceType: "app-url",
|
||||
audience: "https://gateway.example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
@@ -194,12 +195,15 @@ Use these identifiers for delivery and allowlists:
|
||||
Notes:
|
||||
|
||||
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
|
||||
- `serviceAccountRef` is also supported (env/file SecretRef), including per-account refs under `channels.googlechat.accounts.<id>.serviceAccountRef`.
|
||||
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
||||
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
|
||||
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
||||
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
||||
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
||||
|
||||
Secrets reference details: [Secrets Management](/gateway/secrets).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 405 Method Not Allowed
|
||||
|
||||
@@ -184,6 +184,7 @@ Notes:
|
||||
|
||||
- `groupPolicy` is separate from mention-gating (which requires @mentions).
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `channels.slack.channels`.
|
||||
- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
|
||||
|
||||
@@ -43,7 +43,14 @@ Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `sl
|
||||
Stored under `~/.openclaw/credentials/`:
|
||||
|
||||
- Pending requests: `<channel>-pairing.json`
|
||||
- Approved allowlist store: `<channel>-allowFrom.json`
|
||||
- Approved allowlist store:
|
||||
- Default account: `<channel>-allowFrom.json`
|
||||
- Non-default account: `<channel>-<accountId>-allowFrom.json`
|
||||
|
||||
Account scoping behavior:
|
||||
|
||||
- Non-default accounts read/write only their scoped allowlist file.
|
||||
- Default account uses the channel-scoped unscoped allowlist file.
|
||||
|
||||
Treat these as sensitive (they gate access to your assistant).
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||
- `dm.groupEnabled` (group DMs default false)
|
||||
- `dm.groupChannels` (optional MPIM allowlist)
|
||||
|
||||
Multi-account precedence:
|
||||
|
||||
- `channels.slack.accounts.default.allowFrom` applies only to the `default` account.
|
||||
- Named accounts inherit `channels.slack.allowFrom` when their own `allowFrom` is unset.
|
||||
- Named accounts do not inherit `channels.slack.accounts.default.allowFrom`.
|
||||
|
||||
Pairing in DMs uses `openclaw pairing approve slack <code>`.
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -719,6 +719,10 @@ Primary reference:
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs.
|
||||
- Multi-account precedence:
|
||||
- `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account.
|
||||
- Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset.
|
||||
- Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`.
|
||||
- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
|
||||
- `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.requireMention`: mention gating default.
|
||||
|
||||
+49
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw agents` (list/add/delete/set identity)"
|
||||
summary: "CLI reference for `openclaw agents` (list/add/delete/bindings/bind/unbind/set identity)"
|
||||
read_when:
|
||||
- You want multiple isolated agents (workspaces + routing + auth)
|
||||
title: "agents"
|
||||
@@ -19,11 +19,59 @@ Related:
|
||||
```bash
|
||||
openclaw agents list
|
||||
openclaw agents add work --workspace ~/.openclaw/workspace-work
|
||||
openclaw agents bindings
|
||||
openclaw agents bind --agent work --bind telegram:ops
|
||||
openclaw agents unbind --agent work --bind telegram:ops
|
||||
openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity
|
||||
openclaw agents set-identity --agent main --avatar avatars/openclaw.png
|
||||
openclaw agents delete work
|
||||
```
|
||||
|
||||
## Routing bindings
|
||||
|
||||
Use routing bindings to pin inbound channel traffic to a specific agent.
|
||||
|
||||
List bindings:
|
||||
|
||||
```bash
|
||||
openclaw agents bindings
|
||||
openclaw agents bindings --agent work
|
||||
openclaw agents bindings --json
|
||||
```
|
||||
|
||||
Add bindings:
|
||||
|
||||
```bash
|
||||
openclaw agents bind --agent work --bind telegram:ops --bind discord:guild-a
|
||||
```
|
||||
|
||||
If you omit `accountId` (`--bind <channel>`), OpenClaw resolves it from channel defaults and plugin setup hooks when available.
|
||||
|
||||
### Binding scope behavior
|
||||
|
||||
- A binding without `accountId` matches the channel default account only.
|
||||
- `accountId: "*"` is the channel-wide fallback (all accounts) and is less specific than an explicit account binding.
|
||||
- If the same agent already has a matching channel binding without `accountId`, and you later bind with an explicit or resolved `accountId`, OpenClaw upgrades that existing binding in place instead of adding a duplicate.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# initial channel-only binding
|
||||
openclaw agents bind --agent work --bind telegram
|
||||
|
||||
# later upgrade to account-scoped binding
|
||||
openclaw agents bind --agent work --bind telegram:ops
|
||||
```
|
||||
|
||||
After the upgrade, routing for that binding is scoped to `telegram:ops`. If you also want default-account routing, add it explicitly (for example `--bind telegram:default`).
|
||||
|
||||
Remove bindings:
|
||||
|
||||
```bash
|
||||
openclaw agents unbind --agent work --bind telegram:ops
|
||||
openclaw agents unbind --agent work --all
|
||||
```
|
||||
|
||||
## Identity files
|
||||
|
||||
Each agent workspace can include an `IDENTITY.md` at the workspace root:
|
||||
|
||||
@@ -35,6 +35,26 @@ openclaw channels remove --channel telegram --delete
|
||||
|
||||
Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc).
|
||||
|
||||
When you run `openclaw channels add` without flags, the interactive wizard can prompt:
|
||||
|
||||
- account ids per selected channel
|
||||
- optional display names for those accounts
|
||||
- `Bind configured channel accounts to agents now?`
|
||||
|
||||
If you confirm bind now, the wizard asks which agent should own each configured channel account and writes account-scoped routing bindings.
|
||||
|
||||
You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)).
|
||||
|
||||
When you add a non-default account to a channel that is still using single-account top-level settings (no `channels.<channel>.accounts` entries yet), OpenClaw moves account-scoped single-account top-level values into `channels.<channel>.accounts.default`, then writes the new account. This preserves the original account behavior while moving to the multi-account shape.
|
||||
|
||||
Routing behavior stays consistent:
|
||||
|
||||
- Existing channel-only bindings (no `accountId`) continue to match the default account.
|
||||
- `channels add` does not auto-create or rewrite bindings in non-interactive mode.
|
||||
- Interactive setup can optionally add account-scoped bindings.
|
||||
|
||||
If your config was already in a mixed state (named accounts present, missing `default`, and top-level single-account values still set), run `openclaw doctor --fix` to move account-scoped values into `accounts.default`.
|
||||
|
||||
## Login / logout (interactive)
|
||||
|
||||
```bash
|
||||
|
||||
+47
-2
@@ -52,6 +52,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`plugins`](/cli/plugins) (plugin commands)
|
||||
- [`channels`](/cli/channels)
|
||||
- [`security`](/cli/security)
|
||||
- [`secrets`](/cli/secrets)
|
||||
- [`skills`](/cli/skills)
|
||||
- [`daemon`](/cli/daemon) (legacy alias for gateway service commands)
|
||||
- [`clawbot`](/cli/clawbot) (legacy alias namespace)
|
||||
@@ -104,6 +105,9 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
dashboard
|
||||
security
|
||||
audit
|
||||
secrets
|
||||
reload
|
||||
migrate
|
||||
reset
|
||||
uninstall
|
||||
update
|
||||
@@ -263,6 +267,13 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
|
||||
- `openclaw security audit --deep` — best-effort live Gateway probe.
|
||||
- `openclaw security audit --fix` — tighten safe defaults and chmod state/config.
|
||||
|
||||
## Secrets
|
||||
|
||||
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
|
||||
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
|
||||
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
|
||||
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
|
||||
|
||||
## Plugins
|
||||
|
||||
Manage extensions and their config:
|
||||
@@ -317,7 +328,8 @@ Interactive wizard to set up gateway, workspace, and skills.
|
||||
Options:
|
||||
|
||||
- `--workspace <dir>`
|
||||
- `--reset` (reset config + credentials + sessions + workspace before wizard)
|
||||
- `--reset` (reset config + credentials + sessions before wizard)
|
||||
- `--reset-scope <config|config+creds+sessions|full>` (default `config+creds+sessions`; use `full` to also remove workspace)
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
@@ -326,6 +338,7 @@ Options:
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
|
||||
- `--secret-input-mode <plaintext|ref>` (default `plaintext`; use `ref` to store provider default env refs instead of plaintext keys)
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--openai-api-key <key>`
|
||||
- `--mistral-api-key <key>`
|
||||
@@ -400,6 +413,8 @@ Subcommands:
|
||||
- Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `openclaw doctor`).
|
||||
- `channels logs`: show recent channel logs from the gateway log file.
|
||||
- `channels add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
|
||||
- When adding a non-default account to a channel still using single-account top-level config, OpenClaw moves account-scoped values into `channels.<channel>.accounts.default` before writing the new account.
|
||||
- Non-interactive `channels add` does not auto-create/upgrade bindings; channel-only bindings continue to match the default account.
|
||||
- `channels remove`: disable by default; pass `--delete` to remove config entries without prompts.
|
||||
- `channels login`: interactive channel login (WhatsApp Web only).
|
||||
- `channels logout`: log out of a channel session (if supported).
|
||||
@@ -574,7 +589,37 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--json`
|
||||
|
||||
Binding specs use `channel[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used.
|
||||
Binding specs use `channel[:accountId]`. When `accountId` is omitted, OpenClaw may resolve account scope via channel defaults/plugin hooks; otherwise it is a channel binding without explicit account scope.
|
||||
|
||||
#### `agents bindings`
|
||||
|
||||
List routing bindings.
|
||||
|
||||
Options:
|
||||
|
||||
- `--agent <id>`
|
||||
- `--json`
|
||||
|
||||
#### `agents bind`
|
||||
|
||||
Add routing bindings for an agent.
|
||||
|
||||
Options:
|
||||
|
||||
- `--agent <id>`
|
||||
- `--bind <channel[:accountId]>` (repeatable)
|
||||
- `--json`
|
||||
|
||||
#### `agents unbind`
|
||||
|
||||
Remove routing bindings for an agent.
|
||||
|
||||
Options:
|
||||
|
||||
- `--agent <id>`
|
||||
- `--bind <channel[:accountId]>` (repeatable)
|
||||
- `--all`
|
||||
- `--json`
|
||||
|
||||
#### `agents delete <id>`
|
||||
|
||||
|
||||
@@ -34,11 +34,39 @@ openclaw onboard --non-interactive \
|
||||
--custom-base-url "https://llm.example.com/v1" \
|
||||
--custom-model-id "foo-large" \
|
||||
--custom-api-key "$CUSTOM_API_KEY" \
|
||||
--secret-input-mode plaintext \
|
||||
--custom-compatibility openai
|
||||
```
|
||||
|
||||
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||
|
||||
Store provider keys as refs instead of plaintext:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice openai-api-key \
|
||||
--secret-input-mode ref \
|
||||
--accept-risk
|
||||
```
|
||||
|
||||
With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values.
|
||||
For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers.<id>.apiKey` as an env ref (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`).
|
||||
|
||||
Non-interactive `ref` mode contract:
|
||||
|
||||
- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`).
|
||||
- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set.
|
||||
- If an inline key flag is passed without the required env var, onboarding fails fast with guidance.
|
||||
|
||||
Interactive onboarding behavior with reference mode:
|
||||
|
||||
- Choose **Use secret reference** when prompted.
|
||||
- Then choose either:
|
||||
- Environment variable
|
||||
- Configured secret provider (`file` or `exec`)
|
||||
- Onboarding performs a fast preflight validation before saving the ref.
|
||||
- If validation fails, onboarding shows the error and lets you retry.
|
||||
|
||||
Non-interactive Z.AI endpoint choices:
|
||||
|
||||
Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`).
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw secrets` (reload, audit, configure, apply)"
|
||||
read_when:
|
||||
- Re-resolving secret refs at runtime
|
||||
- Auditing plaintext residues and unresolved refs
|
||||
- Configuring SecretRefs and applying one-way scrub changes
|
||||
title: "secrets"
|
||||
---
|
||||
|
||||
# `openclaw secrets`
|
||||
|
||||
Secrets runtime controls.
|
||||
|
||||
Related:
|
||||
|
||||
- Secrets guide: [Secrets Management](/gateway/secrets)
|
||||
- Security guide: [Security](/gateway/security)
|
||||
|
||||
## Reload runtime snapshot
|
||||
|
||||
Re-resolve secret refs and atomically swap runtime snapshot.
|
||||
|
||||
```bash
|
||||
openclaw secrets reload
|
||||
openclaw secrets reload --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Uses gateway RPC method `secrets.reload`.
|
||||
- If resolution fails, gateway keeps last-known-good snapshot.
|
||||
- JSON response includes `warningCount`.
|
||||
|
||||
## Audit
|
||||
|
||||
Scan OpenClaw state for:
|
||||
|
||||
- plaintext secret storage
|
||||
- unresolved refs
|
||||
- precedence drift (`auth-profiles` shadowing config refs)
|
||||
- legacy residues (`auth.json`, OAuth out-of-scope notes)
|
||||
|
||||
```bash
|
||||
openclaw secrets audit
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets audit --json
|
||||
```
|
||||
|
||||
Exit behavior:
|
||||
|
||||
- `--check` exits non-zero on findings.
|
||||
- unresolved refs exit with a higher-priority non-zero code.
|
||||
|
||||
## Configure (interactive helper)
|
||||
|
||||
Build provider + SecretRef changes interactively, run preflight, and optionally apply:
|
||||
|
||||
```bash
|
||||
openclaw secrets configure
|
||||
openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets configure --apply --yes
|
||||
openclaw secrets configure --providers-only
|
||||
openclaw secrets configure --skip-provider-setup
|
||||
openclaw secrets configure --json
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
- Provider setup first (`add/edit/remove` for `secrets.providers` aliases).
|
||||
- Credential mapping second (select fields and assign `{source, provider, id}` refs).
|
||||
- Preflight and optional apply last.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
|
||||
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
|
||||
|
||||
Notes:
|
||||
|
||||
- `configure` targets secret-bearing fields in `openclaw.json`.
|
||||
- Include all secret-bearing fields you intend to migrate (for example both `models.providers.*.apiKey` and `skills.entries.*.apiKey`) so audit can reach a clean state.
|
||||
- It performs preflight resolution before apply.
|
||||
- Apply path is one-way for migrated plaintext values.
|
||||
|
||||
Exec provider safety note:
|
||||
|
||||
- Homebrew installs often expose symlinked binaries under `/opt/homebrew/bin/*`.
|
||||
- Set `allowSymlinkCommand: true` only when needed for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
|
||||
|
||||
## Apply a saved plan
|
||||
|
||||
Apply or preflight a plan generated previously:
|
||||
|
||||
```bash
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json
|
||||
```
|
||||
|
||||
Plan contract details (allowed target paths, validation rules, and failure semantics):
|
||||
|
||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
|
||||
## Why no rollback backups
|
||||
|
||||
`secrets apply` intentionally does not write rollback backups containing old plaintext values.
|
||||
|
||||
Safety comes from strict preflight + atomic-ish apply with best-effort in-memory restore on failure.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Audit first, then configure, then confirm clean:
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets configure
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
|
||||
If `audit --check` still reports plaintext findings after a partial migration, verify you also migrated skill keys (`skills.entries.*.apiKey`) and any other reported target paths.
|
||||
@@ -98,6 +98,9 @@ sequenceDiagram
|
||||
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||
auto‑approved to keep same‑host UX smooth.
|
||||
- All connects must sign the `connect.challenge` nonce.
|
||||
- Signature payload `v3` also binds `platform` + `deviceFamily`; the gateway
|
||||
pins paired metadata on reconnect and requires repair pairing for metadata
|
||||
changes.
|
||||
- **Non‑local** connects still require explicit approval.
|
||||
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
|
||||
remote.
|
||||
|
||||
@@ -70,6 +70,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- Auth: OAuth (ChatGPT)
|
||||
- Example model: `openai-codex/gpt-5.3-codex`
|
||||
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
|
||||
- Default transport is `auto` (WebSocket-first, SSE fallback)
|
||||
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -102,6 +104,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||
- Caution: Antigravity and Gemini CLI OAuth in OpenClaw are unofficial integrations. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed.
|
||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
||||
- Enable: `openclaw plugins enable google-antigravity-auth`
|
||||
- Login: `openclaw models auth login --provider google-antigravity --set-default`
|
||||
|
||||
@@ -207,3 +207,9 @@ mode, pass `--yes` to accept defaults.
|
||||
Custom providers in `models.providers` are written into `models.json` under the
|
||||
agent directory (default `~/.openclaw/agents/<agentId>/models.json`). This file
|
||||
is merged by default unless `models.mode` is set to `replace`.
|
||||
|
||||
Merge mode precedence for matching provider IDs:
|
||||
|
||||
- Non-empty `apiKey`/`baseUrl` already present in the agent `models.json` win.
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to config `models.providers`.
|
||||
- Other provider fields are refreshed from config and normalized catalog data.
|
||||
|
||||
@@ -185,6 +185,12 @@ Bindings are **deterministic** and **most-specific wins**:
|
||||
If multiple bindings match in the same tier, the first one in config order wins.
|
||||
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
|
||||
|
||||
Important account-scope detail:
|
||||
|
||||
- A binding that omits `accountId` matches the default account only.
|
||||
- Use `accountId: "*"` for a channel-wide fallback across all accounts.
|
||||
- If you later add the same binding for the same agent with an explicit account id, OpenClaw upgrades the existing channel-only binding to account-scoped instead of duplicating it.
|
||||
|
||||
## Multiple accounts / phone numbers
|
||||
|
||||
Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify
|
||||
|
||||
@@ -40,8 +40,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
|
||||
|
||||
Secrets are stored **per-agent**:
|
||||
|
||||
- Auth profiles (OAuth + API keys): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- Runtime cache (managed automatically; don’t edit): `~/.openclaw/agents/<agentId>/agent/auth.json`
|
||||
- Auth profiles (OAuth + API keys + optional value-level refs): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- Legacy compatibility file: `~/.openclaw/agents/<agentId>/agent/auth.json`
|
||||
(static `api_key` entries are scrubbed when discovered)
|
||||
|
||||
Legacy import-only file (still supported, but not the main store):
|
||||
|
||||
@@ -49,6 +50,8 @@ Legacy import-only file (still supported, but not the main store):
|
||||
|
||||
All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys)
|
||||
|
||||
For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets).
|
||||
|
||||
## Anthropic setup-token (subscription auth)
|
||||
|
||||
Run `claude setup-token` on any machine, then paste it into OpenClaw:
|
||||
|
||||
+9
-1
@@ -1002,7 +1002,12 @@
|
||||
},
|
||||
{
|
||||
"group": "Agent coordination",
|
||||
"pages": ["tools/agent-send", "tools/subagents", "tools/multi-agent-sandbox-tools"]
|
||||
"pages": [
|
||||
"tools/agent-send",
|
||||
"tools/subagents",
|
||||
"tools/acp-agents",
|
||||
"tools/multi-agent-sandbox-tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Skills",
|
||||
@@ -1135,6 +1140,8 @@
|
||||
"gateway/configuration-reference",
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"gateway/secrets",
|
||||
"gateway/secrets-plan-contract",
|
||||
"gateway/trusted-proxy-auth",
|
||||
"gateway/health",
|
||||
"gateway/heartbeat",
|
||||
@@ -1230,6 +1237,7 @@
|
||||
"cli/qr",
|
||||
"cli/reset",
|
||||
"cli/sandbox",
|
||||
"cli/secrets",
|
||||
"cli/security",
|
||||
"cli/sessions",
|
||||
"cli/setup",
|
||||
|
||||
@@ -0,0 +1,800 @@
|
||||
---
|
||||
summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)"
|
||||
owner: "onutc"
|
||||
status: "draft"
|
||||
last_updated: "2026-02-25"
|
||||
title: "ACP Thread Bound Agents"
|
||||
---
|
||||
|
||||
# ACP Thread Bound Agents
|
||||
|
||||
## Overview
|
||||
|
||||
This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery.
|
||||
|
||||
Related document:
|
||||
|
||||
- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor)
|
||||
|
||||
Target user experience:
|
||||
|
||||
- a user spawns or focuses an ACP session into a thread
|
||||
- user messages in that thread route to the bound ACP session
|
||||
- agent output streams back to the same thread persona
|
||||
- session can be persistent or one shot with explicit cleanup controls
|
||||
|
||||
## Decision summary
|
||||
|
||||
Long term recommendation is a hybrid architecture:
|
||||
|
||||
- OpenClaw core owns ACP control plane concerns
|
||||
- session identity and metadata
|
||||
- thread binding and routing decisions
|
||||
- delivery invariants and duplicate suppression
|
||||
- lifecycle cleanup and recovery semantics
|
||||
- ACP runtime backend is pluggable
|
||||
- first backend is an acpx-backed plugin service
|
||||
- runtime does ACP transport, queueing, cancel, reconnect
|
||||
|
||||
OpenClaw should not reimplement ACP transport internals in core.
|
||||
OpenClaw should not rely on a pure plugin-only interception path for routing.
|
||||
|
||||
## North-star architecture (holy grail)
|
||||
|
||||
Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters.
|
||||
|
||||
Non-negotiable invariants:
|
||||
|
||||
- every ACP thread binding references a valid ACP session record
|
||||
- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`)
|
||||
- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`)
|
||||
- spawn, bind, and initial enqueue are atomic
|
||||
- command retries are idempotent (no duplicate runs or duplicate Discord outputs)
|
||||
- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects
|
||||
|
||||
Long-term ownership model:
|
||||
|
||||
- `AcpSessionManager` is the single ACP writer and orchestrator
|
||||
- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface
|
||||
- per ACP session key, manager owns one in-memory actor (serialized command execution)
|
||||
- adapters (`acpx`, future backends) are transport/runtime implementations only
|
||||
|
||||
Long-term persistence model:
|
||||
|
||||
- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir
|
||||
- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth
|
||||
- store ACP events append-only to support replay, crash recovery, and deterministic delivery
|
||||
|
||||
### Delivery strategy (bridge to holy-grail)
|
||||
|
||||
- short-term bridge
|
||||
- keep current thread binding mechanics and existing ACP config surface
|
||||
- fix metadata-gap bugs and route ACP turns through a single core ACP branch
|
||||
- add idempotency keys and fail-closed routing checks immediately
|
||||
- long-term cutover
|
||||
- move ACP source-of-truth to control-plane DB + actors
|
||||
- make bound-thread delivery purely event-projection based
|
||||
- remove legacy fallback behavior that depends on opportunistic session-entry metadata
|
||||
|
||||
## Why not pure plugin only
|
||||
|
||||
Current plugin hooks are not sufficient for end to end ACP session routing without core changes.
|
||||
|
||||
- inbound routing from thread binding resolves to a session key in core dispatch first
|
||||
- message hooks are fire-and-forget and cannot short-circuit the main reply path
|
||||
- plugin commands are good for control operations but not for replacing core per-turn dispatch flow
|
||||
|
||||
Result:
|
||||
|
||||
- ACP runtime can be pluginized
|
||||
- ACP routing branch must exist in core
|
||||
|
||||
## Existing foundation to reuse
|
||||
|
||||
Already implemented and should remain canonical:
|
||||
|
||||
- thread binding target supports `subagent` and `acp`
|
||||
- inbound thread routing override resolves by binding before normal dispatch
|
||||
- outbound thread identity via webhook in reply delivery
|
||||
- `/focus` and `/unfocus` flow with ACP target compatibility
|
||||
- persistent binding store with restore on startup
|
||||
- unbind lifecycle on archive, delete, unfocus, reset, and delete
|
||||
|
||||
This plan extends that foundation rather than replacing it.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Boundary model
|
||||
|
||||
Core (must be in OpenClaw core):
|
||||
|
||||
- ACP session-mode dispatch branch in the reply pipeline
|
||||
- delivery arbitration to avoid parent plus thread duplication
|
||||
- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration)
|
||||
- lifecycle unbind and runtime detach semantics tied to session reset/delete
|
||||
|
||||
Plugin backend (acpx implementation):
|
||||
|
||||
- ACP runtime worker supervision
|
||||
- acpx process invocation and event parsing
|
||||
- ACP command handlers (`/acp ...`) and operator UX
|
||||
- backend-specific config defaults and diagnostics
|
||||
|
||||
### Runtime ownership model
|
||||
|
||||
- one gateway process owns ACP orchestration state
|
||||
- ACP execution runs in supervised child processes via acpx backend
|
||||
- process strategy is long lived per active ACP session key, not per message
|
||||
|
||||
This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable.
|
||||
|
||||
### Core runtime contract
|
||||
|
||||
Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic:
|
||||
|
||||
```ts
|
||||
export type AcpRuntimePromptMode = "prompt" | "steer";
|
||||
|
||||
export type AcpRuntimeHandle = {
|
||||
sessionKey: string;
|
||||
backend: string;
|
||||
runtimeSessionName: string;
|
||||
};
|
||||
|
||||
export type AcpRuntimeEvent =
|
||||
| { type: "text_delta"; stream: "output" | "thought"; text: string }
|
||||
| { type: "tool_call"; name: string; argumentsText: string }
|
||||
| { type: "done"; usage?: Record<string, number> }
|
||||
| { type: "error"; code: string; message: string; retryable?: boolean };
|
||||
|
||||
export interface AcpRuntime {
|
||||
ensureSession(input: {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
idempotencyKey: string;
|
||||
}): Promise<AcpRuntimeHandle>;
|
||||
|
||||
submit(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
mode: AcpRuntimePromptMode;
|
||||
idempotencyKey: string;
|
||||
}): Promise<{ runtimeRunId: string }>;
|
||||
|
||||
stream(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
runtimeRunId: string;
|
||||
onEvent: (event: AcpRuntimeEvent) => Promise<void> | void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void>;
|
||||
|
||||
cancel(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
runtimeRunId?: string;
|
||||
reason?: string;
|
||||
idempotencyKey: string;
|
||||
}): Promise<void>;
|
||||
|
||||
close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise<void>;
|
||||
|
||||
health?(): Promise<{ ok: boolean; details?: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
Implementation detail:
|
||||
|
||||
- first backend: `AcpxRuntime` shipped as a plugin service
|
||||
- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available
|
||||
|
||||
### Control-plane data model and persistence
|
||||
|
||||
Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery:
|
||||
|
||||
- `acp_sessions`
|
||||
- `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error`
|
||||
- `acp_runs`
|
||||
- `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message`
|
||||
- `acp_bindings`
|
||||
- `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at`
|
||||
- `acp_events`
|
||||
- `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at`
|
||||
- `acp_delivery_checkpoint`
|
||||
- `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at`
|
||||
- `acp_idempotency`
|
||||
- `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)`
|
||||
|
||||
```ts
|
||||
export type AcpSessionMeta = {
|
||||
backend: string;
|
||||
agent: string;
|
||||
runtimeSessionName: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
state: "idle" | "running" | "error";
|
||||
lastActivityAt: number;
|
||||
lastError?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Storage rules:
|
||||
|
||||
- keep `SessionEntry.acp` as a compatibility projection during migration
|
||||
- process ids and sockets stay in memory only
|
||||
- durable lifecycle and run status live in ACP DB, not generic session JSON
|
||||
- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints
|
||||
|
||||
### Routing and delivery
|
||||
|
||||
Inbound:
|
||||
|
||||
- keep current thread binding lookup as first routing step
|
||||
- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig`
|
||||
- explicit `/acp steer` command uses `mode: "steer"`
|
||||
|
||||
Outbound:
|
||||
|
||||
- ACP event stream is normalized to OpenClaw reply chunks
|
||||
- delivery target is resolved through existing bound destination path
|
||||
- when a bound thread is active for that session turn, parent channel completion is suppressed
|
||||
|
||||
Streaming policy:
|
||||
|
||||
- stream partial output with coalescing window
|
||||
- configurable min interval and max chunk bytes to stay under Discord rate limits
|
||||
- final message always emitted on completion or failure
|
||||
|
||||
### State machines and transaction boundaries
|
||||
|
||||
Session state machine:
|
||||
|
||||
- `creating -> idle -> running -> idle`
|
||||
- `running -> cancelling -> idle | error`
|
||||
- `idle -> closed`
|
||||
- `error -> idle | closed`
|
||||
|
||||
Run state machine:
|
||||
|
||||
- `queued -> running -> completed`
|
||||
- `running -> failed | cancelled`
|
||||
- `queued -> cancelled`
|
||||
|
||||
Required transaction boundaries:
|
||||
|
||||
- spawn transaction
|
||||
- create ACP session row
|
||||
- create/update ACP thread binding row
|
||||
- enqueue initial run row
|
||||
- close transaction
|
||||
- mark session closed
|
||||
- delete/expire binding rows
|
||||
- write final close event
|
||||
- cancel transaction
|
||||
- mark target run cancelling/cancelled with idempotency key
|
||||
|
||||
No partial success is allowed across these boundaries.
|
||||
|
||||
### Per-session actor model
|
||||
|
||||
`AcpSessionManager` runs one actor per ACP session key:
|
||||
|
||||
- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects
|
||||
- actor owns runtime handle hydration and runtime adapter process lifecycle for that session
|
||||
- actor writes run events in-order (`seq`) before any Discord delivery
|
||||
- actor updates delivery checkpoints after successful outbound send
|
||||
|
||||
This removes cross-turn races and prevents duplicate or out-of-order thread output.
|
||||
|
||||
### Idempotency and delivery projection
|
||||
|
||||
All external ACP actions must carry idempotency keys:
|
||||
|
||||
- spawn idempotency key
|
||||
- prompt/steer idempotency key
|
||||
- cancel idempotency key
|
||||
- close idempotency key
|
||||
|
||||
Delivery rules:
|
||||
|
||||
- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint`
|
||||
- retries resume from checkpoint without re-sending already delivered chunks
|
||||
- final reply emission is exactly-once per run from projection logic
|
||||
|
||||
### Recovery and self-healing
|
||||
|
||||
On gateway start:
|
||||
|
||||
- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`)
|
||||
- recreate actors lazily on first inbound event or eagerly under configured cap
|
||||
- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter
|
||||
|
||||
On inbound Discord thread message:
|
||||
|
||||
- if binding exists but ACP session is missing, fail closed with explicit stale-binding message
|
||||
- optionally auto-unbind stale binding after operator-safe validation
|
||||
- never silently route stale ACP bindings to normal LLM path
|
||||
|
||||
### Lifecycle and safety
|
||||
|
||||
Supported operations:
|
||||
|
||||
- cancel current run: `/acp cancel`
|
||||
- unbind thread: `/unfocus`
|
||||
- close ACP session: `/acp close`
|
||||
- auto close idle sessions by effective TTL
|
||||
|
||||
TTL policy:
|
||||
|
||||
- effective TTL is minimum of
|
||||
- global/session TTL
|
||||
- Discord thread binding TTL
|
||||
- ACP runtime owner TTL
|
||||
|
||||
Safety controls:
|
||||
|
||||
- allowlist ACP agents by name
|
||||
- restrict workspace roots for ACP sessions
|
||||
- env allowlist passthrough
|
||||
- max concurrent ACP sessions per account and globally
|
||||
- bounded restart backoff for runtime crashes
|
||||
|
||||
## Config surface
|
||||
|
||||
Core keys:
|
||||
|
||||
- `acp.enabled`
|
||||
- `acp.dispatch.enabled` (independent ACP routing kill switch)
|
||||
- `acp.backend` (default `acpx`)
|
||||
- `acp.defaultAgent`
|
||||
- `acp.allowedAgents[]`
|
||||
- `acp.maxConcurrentSessions`
|
||||
- `acp.stream.coalesceIdleMs`
|
||||
- `acp.stream.maxChunkChars`
|
||||
- `acp.runtime.ttlMinutes`
|
||||
- `acp.controlPlane.store` (`sqlite` default)
|
||||
- `acp.controlPlane.storePath`
|
||||
- `acp.controlPlane.recovery.eagerActors`
|
||||
- `acp.controlPlane.recovery.reconcileRunningAfterMs`
|
||||
- `acp.controlPlane.checkpoint.flushEveryEvents`
|
||||
- `acp.controlPlane.checkpoint.flushEveryMs`
|
||||
- `acp.idempotency.ttlHours`
|
||||
- `channels.discord.threadBindings.spawnAcpSessions`
|
||||
|
||||
Plugin/backend keys (acpx plugin section):
|
||||
|
||||
- backend command/path overrides
|
||||
- backend env allowlist
|
||||
- backend per-agent presets
|
||||
- backend startup/stop timeouts
|
||||
- backend max inflight runs per session
|
||||
|
||||
## Implementation specification
|
||||
|
||||
### Control-plane modules (new)
|
||||
|
||||
Add dedicated ACP control-plane modules in core:
|
||||
|
||||
- `src/acp/control-plane/manager.ts`
|
||||
- owns ACP actors, lifecycle transitions, command serialization
|
||||
- `src/acp/control-plane/store.ts`
|
||||
- SQLite schema management, transactions, query helpers
|
||||
- `src/acp/control-plane/events.ts`
|
||||
- typed ACP event definitions and serialization
|
||||
- `src/acp/control-plane/checkpoint.ts`
|
||||
- durable delivery checkpoints and replay cursors
|
||||
- `src/acp/control-plane/idempotency.ts`
|
||||
- idempotency key reservation and response replay
|
||||
- `src/acp/control-plane/recovery.ts`
|
||||
- boot-time reconciliation and actor rehydrate plan
|
||||
|
||||
Compatibility bridge modules:
|
||||
|
||||
- `src/acp/runtime/session-meta.ts`
|
||||
- remains temporarily for projection into `SessionEntry.acp`
|
||||
- must stop being source-of-truth after migration cutover
|
||||
|
||||
### Required invariants (must enforce in code)
|
||||
|
||||
- ACP session creation and thread bind are atomic (single transaction)
|
||||
- there is at most one active run per ACP session actor at a time
|
||||
- event `seq` is strictly increasing per run
|
||||
- delivery checkpoint never advances past last committed event
|
||||
- idempotency replay returns previous success payload for duplicate command keys
|
||||
- stale/missing ACP metadata cannot route into normal non-ACP reply path
|
||||
|
||||
### Core touchpoints
|
||||
|
||||
Core files to change:
|
||||
|
||||
- `src/auto-reply/reply/dispatch-from-config.ts`
|
||||
- ACP branch calls `AcpSessionManager.submit` and event-projection delivery
|
||||
- remove direct ACP fallback that bypasses control-plane invariants
|
||||
- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary)
|
||||
- expose normalized routing keys and idempotency seeds for ACP control plane
|
||||
- `src/config/sessions/types.ts`
|
||||
- keep `SessionEntry.acp` as projection-only compatibility field
|
||||
- `src/gateway/server-methods/sessions.ts`
|
||||
- reset/delete/archive must call ACP manager close/unbind transaction path
|
||||
- `src/infra/outbound/bound-delivery-router.ts`
|
||||
- enforce fail-closed destination behavior for ACP bound session turns
|
||||
- `src/discord/monitor/thread-bindings.ts`
|
||||
- add ACP stale-binding validation helpers wired to control-plane lookups
|
||||
- `src/auto-reply/reply/commands-acp.ts`
|
||||
- route spawn/cancel/close/steer through ACP manager APIs
|
||||
- `src/agents/acp-spawn.ts`
|
||||
- stop ad-hoc metadata writes; call ACP manager spawn transaction
|
||||
- `src/plugin-sdk/**` and plugin runtime bridge
|
||||
- expose ACP backend registration and health semantics cleanly
|
||||
|
||||
Core files explicitly not replaced:
|
||||
|
||||
- `src/discord/monitor/message-handler.preflight.ts`
|
||||
- keep thread binding override behavior as the canonical session-key resolver
|
||||
|
||||
### ACP runtime registry API
|
||||
|
||||
Add a core registry module:
|
||||
|
||||
- `src/acp/runtime/registry.ts`
|
||||
|
||||
Required API:
|
||||
|
||||
```ts
|
||||
export type AcpRuntimeBackend = {
|
||||
id: string;
|
||||
runtime: AcpRuntime;
|
||||
healthy?: () => boolean;
|
||||
};
|
||||
|
||||
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void;
|
||||
export function unregisterAcpRuntimeBackend(id: string): void;
|
||||
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null;
|
||||
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend;
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable
|
||||
- plugin service registers backend on `start` and unregisters on `stop`
|
||||
- runtime lookups are read-only and process-local
|
||||
|
||||
### acpx runtime plugin contract (implementation detail)
|
||||
|
||||
For the first production backend (`extensions/acpx`), OpenClaw and acpx are
|
||||
connected with a strict command contract:
|
||||
|
||||
- backend id: `acpx`
|
||||
- plugin service id: `acpx-runtime`
|
||||
- runtime handle encoding: `runtimeSessionName = acpx:v1:<base64url(json)>`
|
||||
- encoded payload fields:
|
||||
- `name` (acpx named session; uses OpenClaw `sessionKey`)
|
||||
- `agent` (acpx agent command)
|
||||
- `cwd` (session workspace root)
|
||||
- `mode` (`persistent | oneshot`)
|
||||
|
||||
Command mapping:
|
||||
|
||||
- ensure session:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> sessions ensure --name <name>`
|
||||
- prompt turn:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> prompt --session <name> --file -`
|
||||
- cancel:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> cancel --session <name>`
|
||||
- close:
|
||||
- `acpx --format json --json-strict --cwd <cwd> <agent> sessions close <name>`
|
||||
|
||||
Streaming:
|
||||
|
||||
- OpenClaw consumes ndjson events from `acpx --format json --json-strict`
|
||||
- `text` => `text_delta/output`
|
||||
- `thought` => `text_delta/thought`
|
||||
- `tool_call` => `tool_call`
|
||||
- `done` => `done`
|
||||
- `error` => `error`
|
||||
|
||||
### Session schema patch
|
||||
|
||||
Patch `SessionEntry` in `src/config/sessions/types.ts`:
|
||||
|
||||
```ts
|
||||
type SessionAcpMeta = {
|
||||
backend: string;
|
||||
agent: string;
|
||||
runtimeSessionName: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
state: "idle" | "running" | "error";
|
||||
lastActivityAt: number;
|
||||
lastError?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Persisted field:
|
||||
|
||||
- `SessionEntry.acp?: SessionAcpMeta`
|
||||
|
||||
Migration rules:
|
||||
|
||||
- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth)
|
||||
- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp`
|
||||
- phase C: migration command backfills missing ACP rows from valid legacy entries
|
||||
- phase D: remove fallback-read and keep projection optional for UX only
|
||||
- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched
|
||||
|
||||
### Error contract
|
||||
|
||||
Add stable ACP error codes and user-facing messages:
|
||||
|
||||
- `ACP_BACKEND_MISSING`
|
||||
- message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.`
|
||||
- `ACP_BACKEND_UNAVAILABLE`
|
||||
- message: `ACP runtime backend is currently unavailable. Try again in a moment.`
|
||||
- `ACP_SESSION_INIT_FAILED`
|
||||
- message: `Could not initialize ACP session runtime.`
|
||||
- `ACP_TURN_FAILED`
|
||||
- message: `ACP turn failed before completion.`
|
||||
|
||||
Rules:
|
||||
|
||||
- return actionable user-safe message in-thread
|
||||
- log detailed backend/system error only in runtime logs
|
||||
- never silently fall back to normal LLM path when ACP routing was explicitly selected
|
||||
|
||||
### Duplicate delivery arbitration
|
||||
|
||||
Single routing rule for ACP bound turns:
|
||||
|
||||
- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread
|
||||
- do not also send to parent channel for the same turn
|
||||
- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback)
|
||||
- if no active binding exists, use normal session destination behavior
|
||||
|
||||
### Observability and operational readiness
|
||||
|
||||
Required metrics:
|
||||
|
||||
- ACP spawn success/failure count by backend and error code
|
||||
- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time)
|
||||
- ACP actor restart count and restart reason
|
||||
- stale-binding detection count
|
||||
- idempotency replay hit rate
|
||||
- Discord delivery retry and rate-limit counters
|
||||
|
||||
Required logs:
|
||||
|
||||
- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey`
|
||||
- explicit state transition logs for session and run state machines
|
||||
- adapter command logs with redaction-safe arguments and exit summary
|
||||
|
||||
Required diagnostics:
|
||||
|
||||
- `/acp sessions` includes state, active run, last error, and binding status
|
||||
- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings
|
||||
|
||||
### Config precedence and effective values
|
||||
|
||||
ACP enablement precedence:
|
||||
|
||||
- account override: `channels.discord.accounts.<id>.threadBindings.spawnAcpSessions`
|
||||
- channel override: `channels.discord.threadBindings.spawnAcpSessions`
|
||||
- global ACP gate: `acp.enabled`
|
||||
- dispatch gate: `acp.dispatch.enabled`
|
||||
- backend availability: registered backend for `acp.backend`
|
||||
|
||||
Auto-enable behavior:
|
||||
|
||||
- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or
|
||||
`acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true`
|
||||
unless denylisted or explicitly disabled
|
||||
|
||||
TTL effective value:
|
||||
|
||||
- `min(session ttl, discord thread binding ttl, acp runtime ttl)`
|
||||
|
||||
### Test map
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `src/acp/runtime/registry.test.ts` (new)
|
||||
- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new)
|
||||
- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases)
|
||||
- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence)
|
||||
|
||||
Integration tests:
|
||||
|
||||
- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior)
|
||||
- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity)
|
||||
- acpx plugin runtime tests in backend package (service register/start/stop + event normalization)
|
||||
|
||||
Gateway e2e tests:
|
||||
|
||||
- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage)
|
||||
- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery
|
||||
|
||||
### Rollout guard
|
||||
|
||||
Add independent ACP dispatch kill switch:
|
||||
|
||||
- `acp.dispatch.enabled` default `false` for first release
|
||||
- when disabled:
|
||||
- ACP spawn/focus control commands may still bind sessions
|
||||
- ACP dispatch path does not activate
|
||||
- user receives explicit message that ACP dispatch is disabled by policy
|
||||
- after canary validation, default can be flipped to `true` in a later release
|
||||
|
||||
## Command and UX plan
|
||||
|
||||
### New commands
|
||||
|
||||
- `/acp spawn <agent-id> [--mode persistent|oneshot] [--thread auto|here|off]`
|
||||
- `/acp cancel [session]`
|
||||
- `/acp steer <instruction>`
|
||||
- `/acp close [session]`
|
||||
- `/acp sessions`
|
||||
|
||||
### Existing command compatibility
|
||||
|
||||
- `/focus <sessionKey>` continues to support ACP targets
|
||||
- `/unfocus` keeps current semantics
|
||||
- `/session ttl` remains the top level TTL override
|
||||
|
||||
## Phased rollout
|
||||
|
||||
### Phase 0 ADR and schema freeze
|
||||
|
||||
- ship ADR for ACP control-plane ownership and adapter boundaries
|
||||
- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`)
|
||||
- define stable ACP error codes, event contract, and state-transition guards
|
||||
|
||||
### Phase 1 Control-plane foundation in core
|
||||
|
||||
- implement `AcpSessionManager` and per-session actor runtime
|
||||
- implement ACP SQLite store and transaction helpers
|
||||
- implement idempotency store and replay helpers
|
||||
- implement event append + delivery checkpoint modules
|
||||
- wire spawn/cancel/close APIs to manager with transactional guarantees
|
||||
|
||||
### Phase 2 Core routing and lifecycle integration
|
||||
|
||||
- route thread-bound ACP turns from dispatch pipeline into ACP manager
|
||||
- enforce fail-closed routing when ACP binding/session invariants fail
|
||||
- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions
|
||||
- add stale-binding detection and optional auto-unbind policy
|
||||
|
||||
### Phase 3 acpx backend adapter/plugin
|
||||
|
||||
- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`)
|
||||
- add backend health checks and startup/teardown registration
|
||||
- normalize acpx ndjson events into ACP runtime events
|
||||
- enforce backend timeouts, process supervision, and restart/backoff policy
|
||||
|
||||
### Phase 4 Delivery projection and channel UX (Discord first)
|
||||
|
||||
- implement event-driven channel projection with checkpoint resume (Discord first)
|
||||
- coalesce streaming chunks with rate-limit aware flush policy
|
||||
- guarantee exactly-once final completion message per run
|
||||
- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions`
|
||||
|
||||
### Phase 5 Migration and cutover
|
||||
|
||||
- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth
|
||||
- add migration utility for legacy ACP metadata rows
|
||||
- flip read path to ACP SQLite primary
|
||||
- remove legacy fallback routing that depends on missing `SessionEntry.acp`
|
||||
|
||||
### Phase 6 Hardening, SLOs, and scale limits
|
||||
|
||||
- enforce concurrency limits (global/account/session), queue policies, and timeout budgets
|
||||
- add full telemetry, dashboards, and alert thresholds
|
||||
- chaos-test crash recovery and duplicate-delivery suppression
|
||||
- publish runbook for backend outage, DB corruption, and stale-binding remediation
|
||||
|
||||
### Full implementation checklist
|
||||
|
||||
- core control-plane modules and tests
|
||||
- DB migrations and rollback plan
|
||||
- ACP manager API integration across dispatch and commands
|
||||
- adapter registration interface in plugin runtime bridge
|
||||
- acpx adapter implementation and tests
|
||||
- thread-capable channel delivery projection logic with checkpoint replay (Discord first)
|
||||
- lifecycle hooks for reset/delete/archive/unfocus
|
||||
- stale-binding detector and operator-facing diagnostics
|
||||
- config validation and precedence tests for all new ACP keys
|
||||
- operational docs and troubleshooting runbook
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit tests:
|
||||
|
||||
- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close)
|
||||
- ACP state-machine transition guards for sessions and runs
|
||||
- idempotency reservation/replay semantics across all ACP commands
|
||||
- per-session actor serialization and queue ordering
|
||||
- acpx event parser and chunk coalescer
|
||||
- runtime supervisor restart and backoff policy
|
||||
- config precedence and effective TTL calculation
|
||||
- core ACP routing branch selection and fail-closed behavior when backend/session is invalid
|
||||
|
||||
Integration tests:
|
||||
|
||||
- fake ACP adapter process for deterministic streaming and cancel behavior
|
||||
- ACP manager + dispatch integration with transactional persistence
|
||||
- thread-bound inbound routing to ACP session key
|
||||
- thread-bound outbound delivery suppresses parent channel duplication
|
||||
- checkpoint replay recovers after delivery failure and resumes from last event
|
||||
- plugin service registration and teardown of ACP runtime backend
|
||||
|
||||
Gateway e2e tests:
|
||||
|
||||
- spawn ACP with thread, exchange multi-turn prompts, unfocus
|
||||
- gateway restart with persisted ACP DB and bindings, then continue same session
|
||||
- concurrent ACP sessions in multiple threads have no cross-talk
|
||||
- duplicate command retries (same idempotency key) do not create duplicate runs or replies
|
||||
- stale-binding scenario yields explicit error and optional auto-clean behavior
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- Duplicate deliveries during transition
|
||||
- Mitigation: single destination resolver and idempotent event checkpoint
|
||||
- Runtime process churn under load
|
||||
- Mitigation: long lived per session owners + concurrency caps + backoff
|
||||
- Plugin absent or misconfigured
|
||||
- Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path)
|
||||
- Config confusion between subagent and ACP gates
|
||||
- Mitigation: explicit ACP keys and command feedback that includes effective policy source
|
||||
- Control-plane store corruption or migration bugs
|
||||
- Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics
|
||||
- Actor deadlocks or mailbox starvation
|
||||
- Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry
|
||||
|
||||
## Acceptance checklist
|
||||
|
||||
- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord)
|
||||
- all thread messages route to bound ACP session only
|
||||
- ACP outputs appear in the same thread identity with streaming or batches
|
||||
- no duplicate output in parent channel for bound turns
|
||||
- spawn+bind+initial enqueue are atomic in persistent store
|
||||
- ACP command retries are idempotent and do not duplicate runs or outputs
|
||||
- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup
|
||||
- crash restart preserves mapping and resumes multi turn continuity
|
||||
- concurrent thread bound ACP sessions work independently
|
||||
- ACP backend missing state produces clear actionable error
|
||||
- stale bindings are detected and surfaced explicitly (with optional safe auto-clean)
|
||||
- control-plane metrics and diagnostics are available for operators
|
||||
- new unit, integration, and e2e coverage passes
|
||||
|
||||
## Addendum: targeted refactors for current implementation (status)
|
||||
|
||||
These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands.
|
||||
|
||||
### 1) Centralize ACP dispatch policy evaluation (completed)
|
||||
|
||||
- implemented via shared ACP policy helpers in `src/acp/policy.ts`
|
||||
- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic
|
||||
|
||||
### 2) Split ACP command handler by subcommand domain (completed)
|
||||
|
||||
- `src/auto-reply/reply/commands-acp.ts` is now a thin router
|
||||
- subcommand behavior is split into:
|
||||
- `src/auto-reply/reply/commands-acp/lifecycle.ts`
|
||||
- `src/auto-reply/reply/commands-acp/runtime-options.ts`
|
||||
- `src/auto-reply/reply/commands-acp/diagnostics.ts`
|
||||
- shared helpers in `src/auto-reply/reply/commands-acp/shared.ts`
|
||||
|
||||
### 3) Split ACP session manager by responsibility (completed)
|
||||
|
||||
- manager is split into:
|
||||
- `src/acp/control-plane/manager.ts` (public facade + singleton)
|
||||
- `src/acp/control-plane/manager.core.ts` (manager implementation)
|
||||
- `src/acp/control-plane/manager.types.ts` (manager types/deps)
|
||||
- `src/acp/control-plane/manager.utils.ts` (normalization + helper functions)
|
||||
|
||||
### 4) Optional acpx runtime adapter cleanup
|
||||
|
||||
- `extensions/acpx/src/runtime.ts` can be split into:
|
||||
- process execution/supervision
|
||||
- ndjson event parsing/normalization
|
||||
- runtime API surface (`submit`, `cancel`, `close`, etc.)
|
||||
- improves testability and makes backend behavior easier to audit
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP"
|
||||
owner: "onutc"
|
||||
status: "draft"
|
||||
last_updated: "2026-02-25"
|
||||
title: "Unified Runtime Streaming Refactor Plan"
|
||||
---
|
||||
|
||||
# Unified Runtime Streaming Refactor Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior.
|
||||
|
||||
## Why this exists
|
||||
|
||||
- Current behavior is split across multiple runtime-specific shaping paths.
|
||||
- Formatting/coalescing bugs can be fixed in one path but remain in others.
|
||||
- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about.
|
||||
|
||||
## Target architecture
|
||||
|
||||
Single pipeline, runtime-specific adapters:
|
||||
|
||||
1. Runtime adapters emit canonical events only.
|
||||
2. Shared stream assembler coalesces and finalizes text/tool/status events.
|
||||
3. Shared channel projector applies channel-specific chunking/formatting once.
|
||||
4. Shared delivery ledger enforces idempotent send/replay semantics.
|
||||
5. Outbound channel adapter executes sends and records delivery checkpoints.
|
||||
|
||||
Canonical event contract:
|
||||
|
||||
- `turn_started`
|
||||
- `text_delta`
|
||||
- `block_final`
|
||||
- `tool_started`
|
||||
- `tool_finished`
|
||||
- `status`
|
||||
- `turn_completed`
|
||||
- `turn_failed`
|
||||
- `turn_cancelled`
|
||||
|
||||
## Workstreams
|
||||
|
||||
### 1) Canonical streaming contract
|
||||
|
||||
- Define strict event schema + validation in core.
|
||||
- Add adapter contract tests to guarantee each runtime emits compatible events.
|
||||
- Reject malformed runtime events early and surface structured diagnostics.
|
||||
|
||||
### 2) Shared stream processor
|
||||
|
||||
- Replace runtime-specific coalescer/projector logic with one processor.
|
||||
- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush.
|
||||
- Move ACP/main/subagent config resolution into one helper to prevent drift.
|
||||
|
||||
### 3) Shared channel projection
|
||||
|
||||
- Keep channel adapters dumb: accept finalized blocks and send.
|
||||
- Move Discord-specific chunking quirks to channel projector only.
|
||||
- Keep pipeline channel-agnostic before projection.
|
||||
|
||||
### 4) Delivery ledger + replay
|
||||
|
||||
- Add per-turn/per-chunk delivery IDs.
|
||||
- Record checkpoints before and after physical send.
|
||||
- On restart, replay pending chunks idempotently and avoid duplicates.
|
||||
|
||||
### 5) Migration and cutover
|
||||
|
||||
- Phase 1: shadow mode (new pipeline computes output but old path sends; compare).
|
||||
- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk).
|
||||
- Phase 3: delete legacy runtime-specific streaming code.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No changes to ACP policy/permissions model in this refactor.
|
||||
- No channel-specific feature expansion outside projection compatibility fixes.
|
||||
- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity).
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
- Risk: behavioral regressions in existing main/subagent paths.
|
||||
Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests.
|
||||
- Risk: duplicate sends during crash recovery.
|
||||
Mitigation: durable delivery IDs + idempotent replay in delivery adapter.
|
||||
- Risk: runtime adapters diverge again.
|
||||
Mitigation: required shared contract test suite for all adapters.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- All runtimes pass shared streaming contract tests.
|
||||
- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas.
|
||||
- Crash/restart replay sends no duplicate chunk for the same delivery ID.
|
||||
- Legacy ACP projector/coalescer path is removed.
|
||||
- Streaming config resolution is shared and runtime-independent.
|
||||
@@ -14,6 +14,7 @@ use the long‑lived token created by `claude setup-token`.
|
||||
|
||||
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
|
||||
layout.
|
||||
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
|
||||
|
||||
## Recommended Anthropic setup (API key)
|
||||
|
||||
@@ -85,6 +86,11 @@ openclaw models auth paste-token --provider anthropic
|
||||
openclaw models auth paste-token --provider openrouter
|
||||
```
|
||||
|
||||
Auth profile refs are also supported for static credentials:
|
||||
|
||||
- `api_key` credentials can use `keyRef: { source, provider, id }`
|
||||
- `token` credentials can use `tokenRef: { source, provider, id }`
|
||||
|
||||
Automation-friendly check (exit `1` when expired/missing, `2` when expiring):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -628,4 +628,4 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
- If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`.
|
||||
- Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format.
|
||||
- Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
|
||||
- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
|
||||
- See [Providers](/providers) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
|
||||
|
||||
@@ -321,6 +321,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
```
|
||||
|
||||
- Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
|
||||
- Service account SecretRef is also supported (`serviceAccountRef`).
|
||||
- Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
|
||||
- Use `spaces/<spaceId>` or `users/<userId>` for delivery targets.
|
||||
- `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode).
|
||||
@@ -505,6 +506,9 @@ Run multiple accounts per channel (each with its own `accountId`):
|
||||
- Env tokens only apply to the **default** account.
|
||||
- Base channel settings apply to all accounts unless overridden per account.
|
||||
- Use `bindings[].match.accountId` to route each account to a different agent.
|
||||
- If you add a non-default account via `openclaw channels add` (or channel onboarding) while still on a single-account top-level channel config, OpenClaw moves account-scoped top-level single-account values into `channels.<channel>.accounts.default` first so the original account keeps working.
|
||||
- Existing channel-only bindings (no `accountId`) keep matching the default account; account-scoped bindings remain optional.
|
||||
- `openclaw doctor --fix` also repairs mixed shapes by moving account-scoped top-level single-account values into `accounts.default` when named accounts exist but `default` is missing.
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
@@ -1741,6 +1745,10 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
|
||||
|
||||
- Use `authHeader: true` + `headers` for custom auth needs.
|
||||
- Override agent config root with `OPENCLAW_AGENT_DIR` (or `PI_CODING_AGENT_DIR`).
|
||||
- Merge precedence for matching provider IDs:
|
||||
- Non-empty agent `models.json` `apiKey`/`baseUrl` win.
|
||||
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
|
||||
- Use `models.mode: "replace"` when you want config to fully rewrite `models.json`.
|
||||
|
||||
### Provider examples
|
||||
|
||||
@@ -1979,7 +1987,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
|
||||
},
|
||||
entries: {
|
||||
"nano-banana-pro": {
|
||||
apiKey: "GEMINI_KEY_HERE",
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
|
||||
env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" },
|
||||
},
|
||||
peekaboo: { enabled: true },
|
||||
@@ -1991,7 +1999,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
|
||||
|
||||
- `allowBundled`: optional allowlist for bundled skills only (managed/workspace skills unaffected).
|
||||
- `entries.<skillKey>.enabled: false` disables a skill even if bundled/installed.
|
||||
- `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var.
|
||||
- `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var (plaintext string or SecretRef object).
|
||||
|
||||
---
|
||||
|
||||
@@ -2151,7 +2159,8 @@ See [Plugins](/tools/plugin).
|
||||
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
|
||||
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
|
||||
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
|
||||
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
|
||||
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
|
||||
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
|
||||
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
|
||||
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
|
||||
@@ -2377,6 +2386,73 @@ Reference env vars in any config string with `${VAR_NAME}`:
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Secret refs are additive: plaintext values still work.
|
||||
|
||||
### `SecretRef`
|
||||
|
||||
Use one object shape:
|
||||
|
||||
```json5
|
||||
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `provider` pattern: `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `source: "env"` id pattern: `^[A-Z][A-Z0-9_]{0,127}$`
|
||||
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
|
||||
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
|
||||
### Supported fields in config
|
||||
|
||||
- `models.providers.<provider>.apiKey`
|
||||
- `skills.entries.<skillKey>.apiKey`
|
||||
- `channels.googlechat.serviceAccount`
|
||||
- `channels.googlechat.serviceAccountRef`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccount`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
|
||||
|
||||
### Secret providers config
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" }, // optional explicit env provider
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/secrets.json",
|
||||
mode: "json",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
vault: {
|
||||
source: "exec",
|
||||
command: "/usr/local/bin/openclaw-vault-resolver",
|
||||
passEnv: ["PATH", "VAULT_ADDR"],
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
env: "default",
|
||||
file: "filemain",
|
||||
exec: "vault",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `file` provider supports `mode: "json"` and `mode: "singleValue"` (`id` must be `"value"` in singleValue mode).
|
||||
- `exec` provider requires an absolute `command` path and uses protocol payloads on stdin/stdout.
|
||||
- By default, symlink command paths are rejected. Set `allowSymlinkCommand: true` to allow symlink paths while validating the resolved target path.
|
||||
- If `trustedDirs` is configured, the trusted-dir check applies to the resolved target path.
|
||||
- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`.
|
||||
- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only.
|
||||
|
||||
---
|
||||
|
||||
## Auth storage
|
||||
|
||||
```json5
|
||||
@@ -2394,8 +2470,11 @@ Reference env vars in any config string with `${VAR_NAME}`:
|
||||
```
|
||||
|
||||
- Per-agent auth profiles stored at `<agentDir>/auth-profiles.json`.
|
||||
- Auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
|
||||
- Static runtime credentials come from in-memory resolved snapshots; legacy static `auth.json` entries are scrubbed when discovered.
|
||||
- Legacy OAuth imports from `~/.openclaw/credentials/oauth.json`.
|
||||
- See [OAuth](/concepts/oauth).
|
||||
- Secrets runtime behavior and `audit/configure/apply` tooling: [Secrets Management](/gateway/secrets).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -492,6 +492,42 @@ Rules:
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Secret refs (env, file, exec)">
|
||||
For fields that support SecretRef objects, you can use:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
openai: { apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" } },
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
entries: {
|
||||
"nano-banana-pro": {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "filemain",
|
||||
id: "/skills/entries/nano-banana-pro/apiKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccountRef: {
|
||||
source: "exec",
|
||||
provider: "vault",
|
||||
id: "channels/googlechat/serviceAccount",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets).
|
||||
</Accordion>
|
||||
|
||||
See [Environment](/help/environment) for full precedence and sources.
|
||||
|
||||
## Full reference
|
||||
|
||||
@@ -121,6 +121,7 @@ Current migrations:
|
||||
- `routing.agentToAgent` → `tools.agentToAgent`
|
||||
- `routing.transcribeAudio` → `tools.media.audio.models`
|
||||
- `bindings[].match.accountID` → `bindings[].match.accountId`
|
||||
- For channels with named `accounts` but missing `accounts.default`, move account-scoped top-level single-account channel values into `channels.<channel>.accounts.default` when present
|
||||
- `identity` → `agents.list[].identity`
|
||||
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
|
||||
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
||||
|
||||
@@ -16,6 +16,12 @@ Use this page for day-1 startup and day-2 operations of the Gateway service.
|
||||
<Card title="Configuration" icon="sliders" href="/gateway/configuration">
|
||||
Task-oriented setup guide + full configuration reference.
|
||||
</Card>
|
||||
<Card title="Secrets management" icon="key-round" href="/gateway/secrets">
|
||||
SecretRef contract, runtime snapshot behavior, and migrate/reload operations.
|
||||
</Card>
|
||||
<Card title="Secrets plan contract" icon="shield-check" href="/gateway/secrets-plan-contract">
|
||||
Exact `secrets apply` target/path rules and ref-only auth-profile behavior.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 5-minute local startup
|
||||
@@ -94,6 +100,7 @@ openclaw gateway status --json
|
||||
openclaw gateway install
|
||||
openclaw gateway restart
|
||||
openclaw gateway stop
|
||||
openclaw secrets reload
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
@@ -215,6 +215,10 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
|
||||
is enabled for break-glass use.
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
|
||||
in addition to device/client/role/scopes/token/nonce fields.
|
||||
- Legacy `v2` signatures remain accepted for compatibility, but paired-device
|
||||
metadata pinning still controls command policy on reconnect.
|
||||
|
||||
## TLS + pinning
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ Gateway call/probe credential resolution now follows one shared contract:
|
||||
|
||||
- Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win.
|
||||
- Local mode defaults:
|
||||
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password`
|
||||
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
|
||||
- Remote mode defaults:
|
||||
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
|
||||
@@ -134,7 +134,8 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
|
||||
|
||||
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
|
||||
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
|
||||
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
|
||||
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
|
||||
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
|
||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
|
||||
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
summary: "Contract for `secrets apply` plans: allowed target paths, validation, and ref-only auth-profile behavior"
|
||||
read_when:
|
||||
- Generating or reviewing `openclaw secrets apply` plan files
|
||||
- Debugging `Invalid plan target path` errors
|
||||
- Understanding how `keyRef` and `tokenRef` influence implicit provider discovery
|
||||
title: "Secrets Apply Plan Contract"
|
||||
---
|
||||
|
||||
# Secrets apply plan contract
|
||||
|
||||
This page defines the strict contract enforced by `openclaw secrets apply`.
|
||||
|
||||
If a target does not match these rules, apply fails before mutating config.
|
||||
|
||||
## Plan file shape
|
||||
|
||||
`openclaw secrets apply --from <plan.json>` expects a `targets` array of plan targets:
|
||||
|
||||
```json5
|
||||
{
|
||||
version: 1,
|
||||
protocolVersion: 1,
|
||||
targets: [
|
||||
{
|
||||
type: "models.providers.apiKey",
|
||||
path: "models.providers.openai.apiKey",
|
||||
pathSegments: ["models", "providers", "openai", "apiKey"],
|
||||
providerId: "openai",
|
||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Allowed target types and paths
|
||||
|
||||
| `target.type` | Allowed `target.path` shape | Optional id match rule |
|
||||
| ------------------------------------ | --------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `models.providers.apiKey` | `models.providers.<providerId>.apiKey` | `providerId` must match `<providerId>` when present |
|
||||
| `skills.entries.apiKey` | `skills.entries.<skillKey>.apiKey` | n/a |
|
||||
| `channels.googlechat.serviceAccount` | `channels.googlechat.serviceAccount` | `accountId` must be empty/omitted |
|
||||
| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts.<accountId>.serviceAccount` | `accountId` must match `<accountId>` when present |
|
||||
|
||||
## Path validation rules
|
||||
|
||||
Each target is validated with all of the following:
|
||||
|
||||
- `type` must be one of the allowed target types above.
|
||||
- `path` must be a non-empty dot path.
|
||||
- `pathSegments` can be omitted. If provided, it must normalize to exactly the same path as `path`.
|
||||
- Forbidden segments are rejected: `__proto__`, `prototype`, `constructor`.
|
||||
- The normalized path must match one of the allowed path shapes for the target type.
|
||||
- If `providerId` / `accountId` is set, it must match the id encoded in the path.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
If a target fails validation, apply exits with an error like:
|
||||
|
||||
```text
|
||||
Invalid plan target path for models.providers.apiKey: models.providers.openai.baseUrl
|
||||
```
|
||||
|
||||
No partial mutation is committed for that invalid target path.
|
||||
|
||||
## Ref-only auth profiles and implicit providers
|
||||
|
||||
Implicit provider discovery also considers auth profiles that store refs instead of plaintext credentials:
|
||||
|
||||
- `type: "api_key"` profiles can use `keyRef` (for example env-backed refs).
|
||||
- `type: "token"` profiles can use `tokenRef`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- For API-key providers (for example `volcengine`, `byteplus`), ref-only profiles can still activate implicit provider entries.
|
||||
- For `github-copilot`, if the profile has no plaintext token, discovery will try `tokenRef` env resolution before token exchange.
|
||||
|
||||
## Operator checks
|
||||
|
||||
```bash
|
||||
# Validate plan without writes
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
|
||||
# Then apply for real
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
```
|
||||
|
||||
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to one of the allowed shapes above.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Secrets Management](/gateway/secrets)
|
||||
- [CLI `secrets`](/cli/secrets)
|
||||
- [Configuration Reference](/gateway/configuration-reference)
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing"
|
||||
read_when:
|
||||
- Configuring SecretRefs for providers, auth profiles, skills, or Google Chat
|
||||
- Operating secrets reload/audit/configure/apply safely in production
|
||||
- Understanding fail-fast and last-known-good behavior
|
||||
title: "Secrets Management"
|
||||
---
|
||||
|
||||
# Secrets management
|
||||
|
||||
OpenClaw supports additive secret references so credentials do not need to be stored as plaintext in config files.
|
||||
|
||||
Plaintext still works. Secret refs are optional.
|
||||
|
||||
## Goals and runtime model
|
||||
|
||||
Secrets are resolved into an in-memory runtime snapshot.
|
||||
|
||||
- Resolution is eager during activation, not lazy on request paths.
|
||||
- Startup fails fast if any referenced credential cannot be resolved.
|
||||
- Reload uses atomic swap: full success or keep last-known-good.
|
||||
- Runtime requests read from the active in-memory snapshot.
|
||||
|
||||
This keeps secret-provider outages off the hot request path.
|
||||
|
||||
## Onboarding reference preflight
|
||||
|
||||
When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving:
|
||||
|
||||
- Env refs: validates env var name and confirms a non-empty value is visible during onboarding.
|
||||
- Provider refs (`file` or `exec`): validates the selected provider, resolves the provided `id`, and checks value type.
|
||||
|
||||
If validation fails, onboarding shows the error and lets you retry.
|
||||
|
||||
## SecretRef contract
|
||||
|
||||
Use one object shape everywhere:
|
||||
|
||||
```json5
|
||||
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
|
||||
```
|
||||
|
||||
### `source: "env"`
|
||||
|
||||
```json5
|
||||
{ source: "env", provider: "default", id: "OPENAI_API_KEY" }
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Z][A-Z0-9_]{0,127}$`
|
||||
|
||||
### `source: "file"`
|
||||
|
||||
```json5
|
||||
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must be an absolute JSON pointer (`/...`)
|
||||
- RFC6901 escaping in segments: `~` => `~0`, `/` => `~1`
|
||||
|
||||
### `source: "exec"`
|
||||
|
||||
```json5
|
||||
{ source: "exec", provider: "vault", id: "providers/openai/apiKey" }
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
|
||||
- `id` must match `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
|
||||
## Provider config
|
||||
|
||||
Define providers under `secrets.providers`:
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/secrets.json",
|
||||
mode: "json", // or "singleValue"
|
||||
},
|
||||
vault: {
|
||||
source: "exec",
|
||||
command: "/usr/local/bin/openclaw-vault-resolver",
|
||||
args: ["--profile", "prod"],
|
||||
passEnv: ["PATH", "VAULT_ADDR"],
|
||||
jsonOnly: true,
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
env: "default",
|
||||
file: "filemain",
|
||||
exec: "vault",
|
||||
},
|
||||
resolution: {
|
||||
maxProviderConcurrency: 4,
|
||||
maxRefsPerProvider: 512,
|
||||
maxBatchBytes: 262144,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Env provider
|
||||
|
||||
- Optional allowlist via `allowlist`.
|
||||
- Missing/empty env values fail resolution.
|
||||
|
||||
### File provider
|
||||
|
||||
- Reads local file from `path`.
|
||||
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
|
||||
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
|
||||
- Path must pass ownership/permission checks.
|
||||
|
||||
### Exec provider
|
||||
|
||||
- Runs configured absolute binary path, no shell.
|
||||
- By default, `command` must point to a regular file (not a symlink).
|
||||
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
|
||||
- Enable `allowSymlinkCommand` only when required for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
|
||||
- When `trustedDirs` is set, checks apply to the resolved target path.
|
||||
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
|
||||
- Request payload (stdin):
|
||||
|
||||
```json
|
||||
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
|
||||
```
|
||||
|
||||
- Response payload (stdout):
|
||||
|
||||
```json
|
||||
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "sk-..." } }
|
||||
```
|
||||
|
||||
Optional per-id errors:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"values": {},
|
||||
"errors": { "providers/openai/apiKey": { "message": "not found" } }
|
||||
}
|
||||
```
|
||||
|
||||
## Exec integration examples
|
||||
|
||||
### 1Password CLI
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
onepassword_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/op",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["read", "op://Personal/OpenClaw QA API Key/password"],
|
||||
passEnv: ["HOME"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### HashiCorp Vault CLI
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
vault_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/vault",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["kv", "get", "-field=OPENAI_API_KEY", "secret/openclaw"],
|
||||
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `sops`
|
||||
|
||||
```json5
|
||||
{
|
||||
secrets: {
|
||||
providers: {
|
||||
sops_openai: {
|
||||
source: "exec",
|
||||
command: "/opt/homebrew/bin/sops",
|
||||
allowSymlinkCommand: true, // required for Homebrew symlinked binaries
|
||||
trustedDirs: ["/opt/homebrew"],
|
||||
args: ["-d", "--extract", '["providers"]["openai"]["apiKey"]', "/path/to/secrets.enc.json"],
|
||||
passEnv: ["SOPS_AGE_KEY_FILE"],
|
||||
jsonOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [{ id: "gpt-5", name: "gpt-5" }],
|
||||
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## In-scope fields (v1)
|
||||
|
||||
### `~/.openclaw/openclaw.json`
|
||||
|
||||
- `models.providers.<provider>.apiKey`
|
||||
- `skills.entries.<skillKey>.apiKey`
|
||||
- `channels.googlechat.serviceAccount`
|
||||
- `channels.googlechat.serviceAccountRef`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccount`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
|
||||
|
||||
### `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
|
||||
- `profiles.<profileId>.keyRef` for `type: "api_key"`
|
||||
- `profiles.<profileId>.tokenRef` for `type: "token"`
|
||||
|
||||
OAuth credential storage changes are out of scope.
|
||||
|
||||
## Required behavior and precedence
|
||||
|
||||
- Field without ref: unchanged.
|
||||
- Field with ref: required at activation time.
|
||||
- If plaintext and ref both exist, ref wins at runtime and plaintext is ignored.
|
||||
|
||||
Warning code:
|
||||
|
||||
- `SECRETS_REF_OVERRIDES_PLAINTEXT`
|
||||
|
||||
## Activation triggers
|
||||
|
||||
Secret activation is attempted on:
|
||||
|
||||
- Startup (preflight plus final activation)
|
||||
- Config reload hot-apply path
|
||||
- Config reload restart-check path
|
||||
- Manual reload via `secrets.reload`
|
||||
|
||||
Activation contract:
|
||||
|
||||
- Success swaps the snapshot atomically.
|
||||
- Startup failure aborts gateway startup.
|
||||
- Runtime reload failure keeps last-known-good snapshot.
|
||||
|
||||
## Degraded and recovered operator signals
|
||||
|
||||
When reload-time activation fails after a healthy state, OpenClaw enters degraded secrets state.
|
||||
|
||||
One-shot system event and log codes:
|
||||
|
||||
- `SECRETS_RELOADER_DEGRADED`
|
||||
- `SECRETS_RELOADER_RECOVERED`
|
||||
|
||||
Behavior:
|
||||
|
||||
- Degraded: runtime keeps last-known-good snapshot.
|
||||
- Recovered: emitted once after a successful activation.
|
||||
- Repeated failures while already degraded log warnings but do not spam events.
|
||||
- Startup fail-fast does not emit degraded events because no runtime snapshot exists yet.
|
||||
|
||||
## Audit and configure workflow
|
||||
|
||||
Use this default operator flow:
|
||||
|
||||
```bash
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets configure
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
|
||||
Migration completeness:
|
||||
|
||||
- Include `skills.entries.<skillKey>.apiKey` targets when those skills use API keys.
|
||||
- If `audit --check` still reports plaintext findings after a partial migration, migrate the remaining reported paths and rerun audit.
|
||||
|
||||
### `secrets audit`
|
||||
|
||||
Findings include:
|
||||
|
||||
- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`)
|
||||
- unresolved refs
|
||||
- precedence shadowing (`auth-profiles` taking priority over config refs)
|
||||
- legacy residues (`auth.json`, OAuth out-of-scope reminders)
|
||||
|
||||
### `secrets configure`
|
||||
|
||||
Interactive helper that:
|
||||
|
||||
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
|
||||
- lets you select secret-bearing fields in `openclaw.json`
|
||||
- captures SecretRef details (`source`, `provider`, `id`)
|
||||
- runs preflight resolution
|
||||
- can apply immediately
|
||||
|
||||
Helpful modes:
|
||||
|
||||
- `openclaw secrets configure --providers-only`
|
||||
- `openclaw secrets configure --skip-provider-setup`
|
||||
|
||||
`configure` apply defaults to:
|
||||
|
||||
- scrub matching static creds from `auth-profiles.json` for targeted providers
|
||||
- scrub legacy static `api_key` entries from `auth.json`
|
||||
- scrub matching known secret lines from `<config-dir>/.env`
|
||||
|
||||
### `secrets apply`
|
||||
|
||||
Apply a saved plan:
|
||||
|
||||
```bash
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
```
|
||||
|
||||
For strict target/path contract details and exact rejection rules, see:
|
||||
|
||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
|
||||
## One-way safety policy
|
||||
|
||||
OpenClaw intentionally does **not** write rollback backups that contain pre-migration plaintext secret values.
|
||||
|
||||
Safety model:
|
||||
|
||||
- preflight must succeed before write mode
|
||||
- runtime activation is validated before commit
|
||||
- apply updates files using atomic file replacement and best-effort in-memory restore on failure
|
||||
|
||||
## `auth.json` compatibility notes
|
||||
|
||||
For static credentials, OpenClaw runtime no longer depends on plaintext `auth.json`.
|
||||
|
||||
- Runtime credential source is the resolved in-memory snapshot.
|
||||
- Legacy `auth.json` static `api_key` entries are scrubbed when discovered.
|
||||
- OAuth-related legacy compatibility behavior remains separate.
|
||||
|
||||
## Related docs
|
||||
|
||||
- CLI commands: [secrets](/cli/secrets)
|
||||
- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
- Auth setup: [Authentication](/gateway/authentication)
|
||||
- Security posture: [Security](/gateway/security)
|
||||
- Environment precedence: [Environment Variables](/help/environment)
|
||||
@@ -202,8 +202,11 @@ Use this when auditing access or deciding what to back up:
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**: `~/.openclaw/credentials/<channel>-allowFrom.json`
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
|
||||
## Security Audit Checklist
|
||||
@@ -488,7 +491,7 @@ If you run multiple accounts on the same channel, use `per-account-channel-peer`
|
||||
OpenClaw has two separate “who can trigger me?” layers:
|
||||
|
||||
- **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
|
||||
- When `dmPolicy="pairing"`, approvals are written to `~/.openclaw/credentials/<channel>-allowFrom.json` (merged with config allowlists).
|
||||
- When `dmPolicy="pairing"`, approvals are written to the account-scoped pairing allowlist store under `~/.openclaw/credentials/` (`<channel>-allowFrom.json` for default account, `<channel>-<accountId>-allowFrom.json` for non-default accounts), merged with config allowlists.
|
||||
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all.
|
||||
- Common patterns:
|
||||
- `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
|
||||
@@ -683,8 +686,10 @@ Set a token so **all** WS clients must authenticate:
|
||||
|
||||
Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
|
||||
|
||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
Note: `gateway.remote.token` / `.password` are client credential sources. They
|
||||
do **not** protect local WS access by themselves.
|
||||
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
|
||||
is unset.
|
||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||
|
||||
Local device pairing:
|
||||
@@ -758,7 +763,9 @@ Assume anything under `~/.openclaw/` (or `$OPENCLAW_STATE_DIR/`) may contain sec
|
||||
|
||||
- `openclaw.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
|
||||
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
|
||||
- `agents/<agentId>/agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`).
|
||||
- `agents/<agentId>/agent/auth-profiles.json`: API keys, token profiles, OAuth tokens, and optional `keyRef`/`tokenRef`.
|
||||
- `secrets.json` (optional): file-backed secret payload used by `file` SecretRef providers (`secrets.providers`).
|
||||
- `agents/<agentId>/agent/auth.json`: legacy compatibility file. Static `api_key` entries are scrubbed when discovered.
|
||||
- `agents/<agentId>/sessions/**`: session transcripts (`*.jsonl`) + routing metadata (`sessions.json`) that can contain private messages and tool output.
|
||||
- `extensions/**`: installed plugins (plus their `node_modules/`).
|
||||
- `sandboxes/**`: tool sandbox workspaces; can accumulate copies of files you read/write inside the sandbox.
|
||||
@@ -1057,7 +1064,7 @@ If your AI does something bad:
|
||||
|
||||
1. Rotate Gateway auth (`gateway.auth.token` / `OPENCLAW_GATEWAY_PASSWORD`) and restart.
|
||||
2. Rotate remote client secrets (`gateway.remote.token` / `.password`) on any machine that can call the Gateway.
|
||||
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`).
|
||||
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`, and encrypted secrets payload values when used).
|
||||
|
||||
### Audit
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ You can reference env vars directly in config string values using `${VAR_NAME}`
|
||||
|
||||
See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details.
|
||||
|
||||
## Secret refs vs `${ENV}` strings
|
||||
|
||||
OpenClaw supports two env-driven patterns:
|
||||
|
||||
- `${VAR}` string substitution in config values.
|
||||
- SecretRef objects (`{ source: "env", provider: "default", id: "VAR" }`) for fields that support secrets references.
|
||||
|
||||
Both resolve from process env at activation time. SecretRef details are documented in [Secrets Management](/gateway/secrets).
|
||||
|
||||
## Path-related env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
|
||||
+14
-12
@@ -1291,16 +1291,17 @@ Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory
|
||||
|
||||
Everything lives under `$OPENCLAW_STATE_DIR` (default: `~/.openclaw`):
|
||||
|
||||
| Path | Purpose |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `$OPENCLAW_STATE_DIR/openclaw.json` | Main config (JSON5) |
|
||||
| `$OPENCLAW_STATE_DIR/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth.json` | Runtime auth cache (managed automatically) |
|
||||
| `$OPENCLAW_STATE_DIR/credentials/` | Provider state (e.g. `whatsapp/<accountId>/creds.json`) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/` | Per-agent state (agentDir + sessions) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/` | Conversation history & state (per agent) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/sessions.json` | Session metadata (per agent) |
|
||||
| Path | Purpose |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `$OPENCLAW_STATE_DIR/openclaw.json` | Main config (JSON5) |
|
||||
| `$OPENCLAW_STATE_DIR/credentials/oauth.json` | Legacy OAuth import (copied into auth profiles on first use) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth-profiles.json` | Auth profiles (OAuth, API keys, and optional `keyRef`/`tokenRef`) |
|
||||
| `$OPENCLAW_STATE_DIR/secrets.json` | Optional file-backed secret payload for `file` SecretRef providers |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/agent/auth.json` | Legacy compatibility file (static `api_key` entries scrubbed) |
|
||||
| `$OPENCLAW_STATE_DIR/credentials/` | Provider state (e.g. `whatsapp/<accountId>/creds.json`) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/` | Per-agent state (agentDir + sessions) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/` | Conversation history & state (per agent) |
|
||||
| `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/sessions.json` | Session metadata (per agent) |
|
||||
|
||||
Legacy single-agent path: `~/.openclaw/agent/*` (migrated by `openclaw doctor`).
|
||||
|
||||
@@ -1338,7 +1339,7 @@ Put your **agent workspace** in a **private** git repo and back it up somewhere
|
||||
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER
|
||||
files, and lets you restore the assistant's "mind" later.
|
||||
|
||||
Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens).
|
||||
Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens, or encrypted secrets payloads).
|
||||
If you need a full restore, back up both the workspace and the state directory
|
||||
separately (see the migration question above).
|
||||
|
||||
@@ -1404,7 +1405,8 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway.remote.token` is for **remote CLI calls** only; it does not enable local gateway auth.
|
||||
- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves.
|
||||
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
|
||||
- The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs.
|
||||
|
||||
### Why do I need a token on localhost now
|
||||
|
||||
@@ -336,6 +336,11 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con
|
||||
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
|
||||
- Plugins (custom extension load + registry smoke): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
|
||||
|
||||
Manual ACP plain-language thread smoke (not CI):
|
||||
|
||||
- `bun scripts/dev/discord-acp-plain-language-smoke.ts --channel <discord-channel-id> ...`
|
||||
- Keep this script for regression/debug workflows. It may be needed again for ACP thread routing validation, so do not delete it.
|
||||
|
||||
Useful env vars:
|
||||
|
||||
- `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw`
|
||||
|
||||
@@ -26,6 +26,7 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing)
|
||||
## Requirements
|
||||
|
||||
- Docker Desktop (or Docker Engine) + Docker Compose v2
|
||||
- At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137)
|
||||
- Enough disk for images + logs
|
||||
|
||||
## Containerized Gateway (Docker Compose)
|
||||
|
||||
+30
-6
@@ -114,10 +114,11 @@ gcloud services enable compute.googleapis.com
|
||||
|
||||
**Machine types:**
|
||||
|
||||
| Type | Specs | Cost | Notes |
|
||||
| -------- | ------------------------ | ------------------ | ------------------ |
|
||||
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended |
|
||||
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load |
|
||||
| Type | Specs | Cost | Notes |
|
||||
| --------- | ------------------------ | ------------------ | -------------------------------------------- |
|
||||
| e2-medium | 2 vCPU, 4GB RAM | ~$25/mo | Most reliable for local Docker builds |
|
||||
| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Minimum recommended for Docker build |
|
||||
| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | Often fails with Docker build OOM (exit 137) |
|
||||
|
||||
**CLI:**
|
||||
|
||||
@@ -350,6 +351,16 @@ docker compose build
|
||||
docker compose up -d openclaw-gateway
|
||||
```
|
||||
|
||||
If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds.
|
||||
|
||||
When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing:
|
||||
|
||||
```bash
|
||||
docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins '["http://127.0.0.1:18789"]' --strict-json
|
||||
```
|
||||
|
||||
If you changed the gateway port, replace `18789` with your configured port.
|
||||
|
||||
Verify binaries:
|
||||
|
||||
```bash
|
||||
@@ -394,7 +405,20 @@ Open in your browser:
|
||||
|
||||
`http://127.0.0.1:18789/`
|
||||
|
||||
Paste your gateway token.
|
||||
Fetch a fresh tokenized dashboard link:
|
||||
|
||||
```bash
|
||||
docker compose run --rm openclaw-cli dashboard --no-open
|
||||
```
|
||||
|
||||
Paste the token from that URL.
|
||||
|
||||
If Control UI shows `unauthorized` or `disconnected (1008): pairing required`, approve the browser device:
|
||||
|
||||
```bash
|
||||
docker compose run --rm openclaw-cli devices list
|
||||
docker compose run --rm openclaw-cli devices approve <requestId>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -449,7 +473,7 @@ Ensure your account has the required IAM permissions (Compute OS Login or Comput
|
||||
|
||||
**Out of memory (OOM)**
|
||||
|
||||
If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium:
|
||||
If Docker build fails with `Killed` and `exit code 137`, the VM was OOM-killed. Upgrade to e2-small (minimum) or e2-medium (recommended for reliable local builds):
|
||||
|
||||
```bash
|
||||
# Stop the VM first
|
||||
|
||||
@@ -85,6 +85,7 @@ To add quadlet **after** an initial setup that did not use it, re-run: `./setup-
|
||||
- **Token:** Stored in `~openclaw/.openclaw/.env` as `OPENCLAW_GATEWAY_TOKEN`. `setup-podman.sh` and `run-openclaw-podman.sh` generate it if missing (uses `openssl`, `python3`, or `od`).
|
||||
- **Optional:** In that `.env` you can set provider keys (e.g. `GROQ_API_KEY`, `OLLAMA_API_KEY`) and other OpenClaw env vars.
|
||||
- **Host ports:** By default the script maps `18789` (gateway) and `18790` (bridge). Override the **host** port mapping with `OPENCLAW_PODMAN_GATEWAY_HOST_PORT` and `OPENCLAW_PODMAN_BRIDGE_HOST_PORT` when launching.
|
||||
- **Gateway bind:** By default, `run-openclaw-podman.sh` starts the gateway with `--bind loopback` for safe local access. To expose on LAN, set `OPENCLAW_GATEWAY_BIND=lan` and configure `gateway.controlUi.allowedOrigins` (or explicitly enable host-header fallback) in `openclaw.json`.
|
||||
- **Paths:** Host config and workspace default to `~openclaw/.openclaw` and `~openclaw/.openclaw/workspace`. Override the host paths used by the launch script with `OPENCLAW_CONFIG_DIR` and `OPENCLAW_WORKSPACE_DIR`.
|
||||
|
||||
## Useful commands
|
||||
|
||||
@@ -232,6 +232,10 @@ await session.prompt(effectivePrompt, { images: imageResult.images });
|
||||
|
||||
The SDK handles the full agent loop: sending to LLM, executing tool calls, streaming responses.
|
||||
|
||||
Image injection is prompt-local: OpenClaw loads image refs from the current prompt and
|
||||
passes them via `images` for that turn only. It does not re-scan older history turns
|
||||
to re-inject image payloads.
|
||||
|
||||
## Tool Architecture
|
||||
|
||||
### Tool Pipeline
|
||||
|
||||
@@ -34,17 +34,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.2.25 \
|
||||
APP_VERSION=2026.2.26 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.25.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.26.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.26.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.25.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.2.25 \
|
||||
APP_VERSION=2026.2.26 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.25.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.26.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.25.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.26.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.2.25.zip` (and `OpenClaw-2026.2.25.dSYM.zip`) to the GitHub release for tag `v2026.2.25`.
|
||||
- Upload `OpenClaw-2026.2.26.zip` (and `OpenClaw-2026.2.26.dSYM.zip`) to the GitHub release for tag `v2026.2.26`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -56,6 +56,33 @@ openclaw models auth login --provider openai-codex
|
||||
}
|
||||
```
|
||||
|
||||
### Codex transport default
|
||||
|
||||
OpenClaw uses `pi-ai` for model streaming. For `openai-codex/*` models you can set
|
||||
`agents.defaults.models.<provider/model>.params.transport` to select transport:
|
||||
|
||||
- Default is `"auto"` (WebSocket-first, then SSE fallback).
|
||||
- `"sse"`: force SSE
|
||||
- `"websocket"`: force WebSocket
|
||||
- `"auto"`: try WebSocket, then fall back to SSE
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai-codex/gpt-5.3-codex" },
|
||||
models: {
|
||||
"openai-codex/gpt-5.3-codex": {
|
||||
params: {
|
||||
transport: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Model refs always use `provider/model` (see [/concepts/models](/concepts/models)).
|
||||
|
||||
@@ -20,6 +20,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**.
|
||||
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
|
||||
(or pass `--reset`).
|
||||
- CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full`
|
||||
to also remove workspace.
|
||||
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
||||
you to run `openclaw doctor` before continuing.
|
||||
- Reset uses `trash` (never `rm`) and offers scopes:
|
||||
@@ -34,7 +36,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
|
||||
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.openclaw/.env` so launchd can read it.
|
||||
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles.
|
||||
- **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider.
|
||||
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth).
|
||||
- **API key**: stores the key for you.
|
||||
@@ -52,6 +54,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- **Skip**: no auth configured yet.
|
||||
- Pick a default model from detected options (or enter provider/model manually).
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
- API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`).
|
||||
- OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
|
||||
- More detail: [/concepts/oauth](/concepts/oauth)
|
||||
<Note>
|
||||
|
||||
@@ -34,6 +34,7 @@ Security trust model:
|
||||
|
||||
- By default, OpenClaw is a personal agent: one trusted operator boundary.
|
||||
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
|
||||
|
||||
</Step>
|
||||
<Step title="Local vs Remote">
|
||||
<Frame>
|
||||
@@ -50,9 +51,11 @@ Where does the **Gateway** run?
|
||||
|
||||
<Tip>
|
||||
**Gateway auth tip:**
|
||||
|
||||
- The wizard now generates a **token** even for loopback, so local WS clients must authenticate.
|
||||
- If you disable auth, any local process can connect; use that only on fully trusted machines.
|
||||
- Use a **token** for multi‑machine access or non‑loopback binds.
|
||||
|
||||
</Tip>
|
||||
</Step>
|
||||
<Step title="Permissions">
|
||||
|
||||
+4
-1
@@ -130,8 +130,11 @@ Use this when debugging auth or deciding what to back up:
|
||||
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
|
||||
- **Discord bot token**: config/env (token file not yet supported)
|
||||
- **Slack tokens**: config/env (`channels.slack.*`)
|
||||
- **Pairing allowlists**: `~/.openclaw/credentials/<channel>-allowFrom.json`
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
- `~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json` (non-default accounts)
|
||||
- **Model auth profiles**: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
- **File-backed secrets payload (optional)**: `~/.openclaw/secrets.json`
|
||||
- **Legacy OAuth import**: `~/.openclaw/credentials/oauth.json`
|
||||
More detail: [Security](/gateway/security#credential-storage-map).
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice apiKey \
|
||||
--anthropic-api-key "$ANTHROPIC_API_KEY" \
|
||||
--secret-input-mode plaintext \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback \
|
||||
--install-daemon \
|
||||
@@ -31,6 +32,22 @@ openclaw onboard --non-interactive \
|
||||
|
||||
Add `--json` for a machine-readable summary.
|
||||
|
||||
Use `--secret-input-mode ref` to store env-backed refs in auth profiles instead of plaintext values.
|
||||
Interactive selection between env refs and configured provider refs (`file` or `exec`) is available in the onboarding wizard flow.
|
||||
|
||||
In non-interactive `ref` mode, provider env vars must be set in the process environment.
|
||||
Passing inline key flags without the matching env var now fails fast.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice openai-api-key \
|
||||
--secret-input-mode ref \
|
||||
--accept-risk
|
||||
```
|
||||
|
||||
## Provider-specific examples
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -132,6 +149,24 @@ Add `--json` for a machine-readable summary.
|
||||
|
||||
`--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||
|
||||
Ref-mode variant:
|
||||
|
||||
```bash
|
||||
export CUSTOM_API_KEY="your-key"
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice custom-api-key \
|
||||
--custom-base-url "https://llm.example.com/v1" \
|
||||
--custom-model-id "foo-large" \
|
||||
--secret-input-mode ref \
|
||||
--custom-provider-id "my-custom" \
|
||||
--custom-compatibility anthropic \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
In this mode, onboarding stores `apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ It does not install or modify anything on the remote host.
|
||||
<Step title="Existing config detection">
|
||||
- If `~/.openclaw/openclaw.json` exists, choose Keep, Modify, or Reset.
|
||||
- Re-running the wizard does not wipe anything unless you explicitly choose Reset (or pass `--reset`).
|
||||
- CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to also remove workspace.
|
||||
- If config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing.
|
||||
- Reset uses `trash` and offers scopes:
|
||||
- Config only
|
||||
@@ -139,8 +140,7 @@ What you set:
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="OpenAI API key">
|
||||
Uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to
|
||||
`~/.openclaw/.env` so launchd can read it.
|
||||
Uses `OPENAI_API_KEY` if present or prompts for a key, then stores the credential in auth profiles.
|
||||
|
||||
Sets `agents.defaults.model` to `openai/gpt-5.1-codex` when model is unset, `openai/*`, or `openai-codex/*`.
|
||||
|
||||
@@ -178,6 +178,10 @@ What you set:
|
||||
<Accordion title="Custom provider">
|
||||
Works with OpenAI-compatible and Anthropic-compatible endpoints.
|
||||
|
||||
Interactive onboarding supports the same API key storage choices as other provider API key flows:
|
||||
- **Paste API key now** (plaintext)
|
||||
- **Use secret reference** (env ref or configured provider ref, with preflight validation)
|
||||
|
||||
Non-interactive flags:
|
||||
- `--auth-choice custom-api-key`
|
||||
- `--custom-base-url`
|
||||
@@ -202,6 +206,24 @@ Credential and profile paths:
|
||||
- OAuth credentials: `~/.openclaw/credentials/oauth.json`
|
||||
- Auth profiles (API keys + OAuth): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
|
||||
API key storage mode:
|
||||
|
||||
- Default onboarding behavior persists API keys as plaintext values in auth profiles.
|
||||
- `--secret-input-mode ref` enables reference mode instead of plaintext key storage.
|
||||
In interactive onboarding, you can choose either:
|
||||
- environment variable ref (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`)
|
||||
- configured provider ref (`file` or `exec`) with provider alias + id
|
||||
- Interactive reference mode runs a fast preflight validation before saving.
|
||||
- Env refs: validates variable name + non-empty value in the current onboarding environment.
|
||||
- Provider refs: validates provider config and resolves the requested id.
|
||||
- If preflight fails, onboarding shows the error and lets you retry.
|
||||
- In non-interactive mode, `--secret-input-mode ref` is env-backed only.
|
||||
- Set the provider env var in the onboarding process environment.
|
||||
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast.
|
||||
- For custom providers, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
|
||||
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast.
|
||||
- Existing plaintext setups continue to work unchanged.
|
||||
|
||||
<Note>
|
||||
Headless and server tip: complete OAuth on a machine with a browser, then copy
|
||||
`~/.openclaw/credentials/oauth.json` (or `$OPENCLAW_STATE_DIR/credentials/oauth.json`)
|
||||
|
||||
@@ -65,6 +65,9 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
|
||||
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
|
||||
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
|
||||
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
|
||||
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
|
||||
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.
|
||||
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
|
||||
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
|
||||
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
|
||||
@@ -74,6 +77,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
|
||||
<Note>
|
||||
Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`).
|
||||
CLI `--reset` defaults to config, credentials, and sessions; use `--reset-scope full` to include workspace.
|
||||
If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first.
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents"
|
||||
read_when:
|
||||
- Running coding harnesses through ACP
|
||||
- Setting up thread-bound ACP sessions on thread-capable channels
|
||||
- Troubleshooting ACP backend and plugin wiring
|
||||
- Operating /acp commands from chat
|
||||
title: "ACP Agents"
|
||||
---
|
||||
|
||||
# ACP agents
|
||||
|
||||
ACP sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin.
|
||||
|
||||
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
|
||||
|
||||
## Fast operator flow
|
||||
|
||||
Use this when you want a practical `/acp` runbook:
|
||||
|
||||
1. Spawn a session:
|
||||
- `/acp spawn codex --mode persistent --thread auto`
|
||||
2. Work in the bound thread (or target that session key explicitly).
|
||||
3. Check runtime state:
|
||||
- `/acp status`
|
||||
4. Tune runtime options as needed:
|
||||
- `/acp model <provider/model>`
|
||||
- `/acp permissions <profile>`
|
||||
- `/acp timeout <seconds>`
|
||||
5. Nudge an active session without replacing context:
|
||||
- `/acp steer tighten logging and continue`
|
||||
6. Stop work:
|
||||
- `/acp cancel` (stop current turn), or
|
||||
- `/acp close` (close session + remove bindings)
|
||||
|
||||
## Quick start for humans
|
||||
|
||||
Examples of natural requests:
|
||||
|
||||
- "Start a persistent Codex session in a thread here and keep it focused."
|
||||
- "Run this as a one-shot Claude Code ACP session and summarize the result."
|
||||
- "Use Gemini CLI for this task in a thread, then keep follow-ups in that same thread."
|
||||
|
||||
What OpenClaw should do:
|
||||
|
||||
1. Pick `runtime: "acp"`.
|
||||
2. Resolve the requested harness target (`agentId`, for example `codex`).
|
||||
3. If thread binding is requested and the current channel supports it, bind the ACP session to the thread.
|
||||
4. Route follow-up thread messages to that same ACP session until unfocused/closed/expired.
|
||||
|
||||
## ACP versus sub-agents
|
||||
|
||||
Use ACP when you want an external harness runtime. Use sub-agents when you want OpenClaw-native delegated runs.
|
||||
|
||||
| Area | ACP session | Sub-agent run |
|
||||
| ------------- | ------------------------------------- | ---------------------------------- |
|
||||
| Runtime | ACP backend plugin (for example acpx) | OpenClaw native sub-agent runtime |
|
||||
| Session key | `agent:<agentId>:acp:<uuid>` | `agent:<agentId>:subagent:<uuid>` |
|
||||
| Main commands | `/acp ...` | `/subagents ...` |
|
||||
| Spawn tool | `sessions_spawn` with `runtime:"acp"` | `sessions_spawn` (default runtime) |
|
||||
|
||||
See also [Sub-agents](/tools/subagents).
|
||||
|
||||
## Thread-bound sessions (channel-agnostic)
|
||||
|
||||
When thread bindings are enabled for a channel adapter, ACP sessions can be bound to threads:
|
||||
|
||||
- OpenClaw binds a thread to a target ACP session.
|
||||
- Follow-up messages in that thread route to the bound ACP session.
|
||||
- ACP output is delivered back to the same thread.
|
||||
- Unfocus/close/archive/TTL expiry removes the binding.
|
||||
|
||||
Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message.
|
||||
|
||||
Required feature flags for thread-bound ACP:
|
||||
|
||||
- `acp.enabled=true`
|
||||
- `acp.dispatch.enabled=true`
|
||||
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
|
||||
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
|
||||
|
||||
### Thread supporting channels
|
||||
|
||||
- Any channel adapter that exposes session/thread binding capability.
|
||||
- Current built-in support: Discord.
|
||||
- Plugin channels can add support through the same binding interface.
|
||||
|
||||
## Start ACP sessions (interfaces)
|
||||
|
||||
### From `sessions_spawn`
|
||||
|
||||
Use `runtime: "acp"` to start an ACP session from an agent turn or tool call.
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Open the repo and summarize failing tests",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"thread": true,
|
||||
"mode": "session"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `runtime` defaults to `subagent`, so set `runtime: "acp"` explicitly for ACP sessions.
|
||||
- If `agentId` is omitted, OpenClaw uses `acp.defaultAgent` when configured.
|
||||
- `mode: "session"` requires `thread: true` to keep a persistent bound conversation.
|
||||
|
||||
Interface details:
|
||||
|
||||
- `task` (required): initial prompt sent to the ACP session.
|
||||
- `runtime` (required for ACP): must be `"acp"`.
|
||||
- `agentId` (optional): ACP target harness id. Falls back to `acp.defaultAgent` if set.
|
||||
- `thread` (optional, default `false`): request thread binding flow where supported.
|
||||
- `mode` (optional): `run` (one-shot) or `session` (persistent).
|
||||
- default is `run`
|
||||
- if `thread: true` and mode omitted, OpenClaw may default to persistent behavior per runtime path
|
||||
- `mode: "session"` requires `thread: true`
|
||||
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
|
||||
- `label` (optional): operator-facing label used in session/banner text.
|
||||
|
||||
### From `/acp` command
|
||||
|
||||
Use `/acp spawn` for explicit operator control from chat when needed.
|
||||
|
||||
```text
|
||||
/acp spawn codex --mode persistent --thread auto
|
||||
/acp spawn codex --mode oneshot --thread off
|
||||
/acp spawn codex --thread here
|
||||
```
|
||||
|
||||
Key flags:
|
||||
|
||||
- `--mode persistent|oneshot`
|
||||
- `--thread auto|here|off`
|
||||
- `--cwd <absolute-path>`
|
||||
- `--label <name>`
|
||||
|
||||
See [Slash Commands](/tools/slash-commands).
|
||||
|
||||
## Session target resolution
|
||||
|
||||
Most `/acp` actions accept an optional session target (`session-key`, `session-id`, or `session-label`).
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. Explicit target argument (or `--session` for `/acp steer`)
|
||||
- tries key
|
||||
- then UUID-shaped session id
|
||||
- then label
|
||||
2. Current thread binding (if this conversation/thread is bound to an ACP session)
|
||||
3. Current requester session fallback
|
||||
|
||||
If no target resolves, OpenClaw returns a clear error (`Unable to resolve session target: ...`).
|
||||
|
||||
## Spawn thread modes
|
||||
|
||||
`/acp spawn` supports `--thread auto|here|off`.
|
||||
|
||||
| Mode | Behavior |
|
||||
| ------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `auto` | In an active thread: bind that thread. Outside a thread: create/bind a child thread when supported. |
|
||||
| `here` | Require current active thread; fail if not in one. |
|
||||
| `off` | No binding. Session starts unbound. |
|
||||
|
||||
Notes:
|
||||
|
||||
- On non-thread binding surfaces, default behavior is effectively `off`.
|
||||
- Thread-bound spawn requires channel policy support (for Discord: `channels.discord.threadBindings.spawnAcpSessions=true`).
|
||||
|
||||
## ACP controls
|
||||
|
||||
Available command family:
|
||||
|
||||
- `/acp spawn`
|
||||
- `/acp cancel`
|
||||
- `/acp steer`
|
||||
- `/acp close`
|
||||
- `/acp status`
|
||||
- `/acp set-mode`
|
||||
- `/acp set`
|
||||
- `/acp cwd`
|
||||
- `/acp permissions`
|
||||
- `/acp timeout`
|
||||
- `/acp model`
|
||||
- `/acp reset-options`
|
||||
- `/acp sessions`
|
||||
- `/acp doctor`
|
||||
- `/acp install`
|
||||
|
||||
`/acp status` shows the effective runtime options and, when available, both runtime-level and backend-level session identifiers.
|
||||
|
||||
Some controls depend on backend capabilities. If a backend does not support a control, OpenClaw returns a clear unsupported-control error.
|
||||
|
||||
## ACP command cookbook
|
||||
|
||||
| Command | What it does | Example |
|
||||
| -------------------- | --------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `/acp spawn` | Create ACP session; optional thread bind. | `/acp spawn codex --mode persistent --thread auto --cwd /repo` |
|
||||
| `/acp cancel` | Cancel in-flight turn for target session. | `/acp cancel agent:codex:acp:<uuid>` |
|
||||
| `/acp steer` | Send steer instruction to running session. | `/acp steer --session support inbox prioritize failing tests` |
|
||||
| `/acp close` | Close session and unbind thread targets. | `/acp close` |
|
||||
| `/acp status` | Show backend, mode, state, runtime options, capabilities. | `/acp status` |
|
||||
| `/acp set-mode` | Set runtime mode for target session. | `/acp set-mode plan` |
|
||||
| `/acp set` | Generic runtime config option write. | `/acp set model openai/gpt-5.2` |
|
||||
| `/acp cwd` | Set runtime working directory override. | `/acp cwd /Users/user/Projects/repo` |
|
||||
| `/acp permissions` | Set approval policy profile. | `/acp permissions strict` |
|
||||
| `/acp timeout` | Set runtime timeout (seconds). | `/acp timeout 120` |
|
||||
| `/acp model` | Set runtime model override. | `/acp model anthropic/claude-opus-4-5` |
|
||||
| `/acp reset-options` | Remove session runtime option overrides. | `/acp reset-options` |
|
||||
| `/acp sessions` | List recent ACP sessions from store. | `/acp sessions` |
|
||||
| `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` |
|
||||
| `/acp install` | Print deterministic install and enable steps. | `/acp install` |
|
||||
|
||||
## Runtime options mapping
|
||||
|
||||
`/acp` has convenience commands and a generic setter.
|
||||
|
||||
Equivalent operations:
|
||||
|
||||
- `/acp model <id>` maps to runtime config key `model`.
|
||||
- `/acp permissions <profile>` maps to runtime config key `approval_policy`.
|
||||
- `/acp timeout <seconds>` maps to runtime config key `timeout`.
|
||||
- `/acp cwd <path>` updates runtime cwd override directly.
|
||||
- `/acp set <key> <value>` is the generic path.
|
||||
- Special case: `key=cwd` uses the cwd override path.
|
||||
- `/acp reset-options` clears all runtime overrides for target session.
|
||||
|
||||
## acpx harness support (current)
|
||||
|
||||
Current acpx built-in harness aliases:
|
||||
|
||||
- `pi`
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
|
||||
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
|
||||
|
||||
Direct acpx CLI usage can also target arbitrary adapters via `--agent <command>`, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path).
|
||||
|
||||
## Required config
|
||||
|
||||
Core ACP baseline:
|
||||
|
||||
```json5
|
||||
{
|
||||
acp: {
|
||||
enabled: true,
|
||||
dispatch: { enabled: true },
|
||||
backend: "acpx",
|
||||
defaultAgent: "codex",
|
||||
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
|
||||
maxConcurrentSessions: 8,
|
||||
stream: {
|
||||
coalesceIdleMs: 300,
|
||||
maxChunkChars: 1200,
|
||||
},
|
||||
runtime: {
|
||||
ttlMinutes: 120,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Thread binding config is channel-adapter specific. Example for Discord:
|
||||
|
||||
```json5
|
||||
{
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
ttlHours: 24,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If thread-bound ACP spawn does not work, verify the adapter feature flag first:
|
||||
|
||||
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
|
||||
|
||||
See [Configuration Reference](/gateway/configuration-reference).
|
||||
|
||||
## Plugin setup for acpx backend
|
||||
|
||||
Install and enable plugin:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/acpx
|
||||
openclaw config set plugins.entries.acpx.enabled true
|
||||
```
|
||||
|
||||
Local workspace install during development:
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/acpx
|
||||
```
|
||||
|
||||
Then verify backend health:
|
||||
|
||||
```text
|
||||
/acp doctor
|
||||
```
|
||||
|
||||
### Pinned acpx install strategy (current behavior)
|
||||
|
||||
`@openclaw/acpx` now enforces a strict plugin-local pinning model:
|
||||
|
||||
1. The extension pins an exact acpx dependency in `extensions/acpx/package.json`.
|
||||
2. Runtime command is fixed to the plugin-local binary (`extensions/acpx/node_modules/.bin/acpx`), not global `PATH`.
|
||||
3. Plugin config does not expose `command` or `commandArgs`, so runtime command drift is blocked.
|
||||
4. Startup registers the ACP backend immediately as not-ready.
|
||||
5. A background ensure job verifies `acpx --version` against the pinned version.
|
||||
6. If missing/mismatched, it runs plugin-local install (`npm install --omit=dev --no-save acpx@<pinned>`) and re-verifies before healthy.
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenClaw startup stays non-blocking while acpx ensure runs.
|
||||
- If network/install fails, backend remains unavailable and `/acp doctor` reports an actionable fix.
|
||||
|
||||
See [Plugins](/tools/plugin).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| ----------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `ACP runtime backend is not configured` | Backend plugin missing or disabled. | Install and enable backend plugin, then run `/acp doctor`. |
|
||||
| `ACP is disabled by policy (acp.enabled=false)` | ACP globally disabled. | Set `acp.enabled=true`. |
|
||||
| `ACP dispatch is disabled by policy (acp.dispatch.enabled=false)` | Dispatch from normal thread messages disabled. | Set `acp.dispatch.enabled=true`. |
|
||||
| `ACP agent "<id>" is not allowed by policy` | Agent not in allowlist. | Use allowed `agentId` or update `acp.allowedAgents`. |
|
||||
| `Unable to resolve session target: ...` | Bad key/id/label token. | Run `/acp sessions`, copy exact key/label, retry. |
|
||||
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
|
||||
| `Only <user-id> can rebind this thread.` | Another user owns thread binding. | Rebind as owner or use a different thread. |
|
||||
| `Thread bindings are unavailable for <channel>.` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
|
||||
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
|
||||
+2
-1
@@ -464,7 +464,7 @@ Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `thinking?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
|
||||
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
|
||||
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
|
||||
|
||||
Notes:
|
||||
@@ -474,6 +474,7 @@ Notes:
|
||||
- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
|
||||
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
|
||||
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
|
||||
- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
|
||||
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
|
||||
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
|
||||
- If `thread: true` and `mode` is omitted, mode defaults to `session`.
|
||||
|
||||
@@ -452,6 +452,29 @@ Notes:
|
||||
- `meta.preferOver` lists channel ids to skip auto-enable when both are configured.
|
||||
- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons.
|
||||
|
||||
### Channel onboarding hooks
|
||||
|
||||
Channel plugins can define optional onboarding hooks on `plugin.onboarding`:
|
||||
|
||||
- `configure(ctx)` is the baseline setup flow.
|
||||
- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states.
|
||||
- `configureWhenConfigured(ctx)` can override behavior only for already configured channels.
|
||||
|
||||
Hook precedence in the wizard:
|
||||
|
||||
1. `configureInteractive` (if present)
|
||||
2. `configureWhenConfigured` (only when channel status is already configured)
|
||||
3. fallback to `configure`
|
||||
|
||||
Context details:
|
||||
|
||||
- `configureInteractive` and `configureWhenConfigured` receive:
|
||||
- `configured` (`true` or `false`)
|
||||
- `label` (user-facing channel name used by prompts)
|
||||
- plus the shared config/runtime/prompter/options fields
|
||||
- Returning `"skip"` leaves selection and account tracking unchanged.
|
||||
- Returning `{ cfg, accountId? }` applies config updates and records account selection.
|
||||
|
||||
### Write a new messaging channel (step‑by‑step)
|
||||
|
||||
Use this when you want a **new chat surface** (a "messaging channel"), not a model provider.
|
||||
|
||||
@@ -26,7 +26,7 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j
|
||||
entries: {
|
||||
"nano-banana-pro": {
|
||||
enabled: true,
|
||||
apiKey: "GEMINI_KEY_HERE",
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
|
||||
env: {
|
||||
GEMINI_API_KEY: "GEMINI_KEY_HERE",
|
||||
},
|
||||
@@ -56,6 +56,7 @@ Per-skill fields:
|
||||
- `enabled`: set `false` to disable a skill even if it’s bundled/installed.
|
||||
- `env`: environment variables injected for the agent run (only if not already set).
|
||||
- `apiKey`: optional convenience for skills that declare a primary env var.
|
||||
Supports plaintext string or SecretRef object (`{ source, provider, id }`).
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ Bundled/managed skills can be toggled and supplied with env values:
|
||||
entries: {
|
||||
"nano-banana-pro": {
|
||||
enabled: true,
|
||||
apiKey: "GEMINI_KEY_HERE",
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
|
||||
env: {
|
||||
GEMINI_API_KEY: "GEMINI_KEY_HERE",
|
||||
},
|
||||
@@ -221,6 +221,7 @@ Rules:
|
||||
- `enabled: false` disables the skill even if it’s bundled/installed.
|
||||
- `env`: injected **only if** the variable isn’t already set in the process.
|
||||
- `apiKey`: convenience for skills that declare `metadata.openclaw.primaryEnv`.
|
||||
Supports plaintext string or SecretRef object (`{ source, provider, id }`).
|
||||
- `config`: optional bag for custom per-skill fields; custom keys must live here.
|
||||
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
|
||||
bundled skills in the list are eligible (managed/workspace skills unaffected).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user