mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 01:31:48 +08:00
Compare commits
51 Commits
vincentkoc
...
feat/cron-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3394b459 | ||
|
|
912b55ac18 | ||
|
|
9560d8cf9a | ||
|
|
d50e2677b6 | ||
|
|
a043668ba4 | ||
|
|
0d020469e4 | ||
|
|
5be223ec1c | ||
|
|
9426abbccb | ||
|
|
2963f49659 | ||
|
|
37b8606e48 | ||
|
|
2620f30500 | ||
|
|
f7b031f2dd | ||
|
|
57e7299e20 | ||
|
|
49ae78865a | ||
|
|
150d4a7cc7 | ||
|
|
1e1180d142 | ||
|
|
31cb9e44dd | ||
|
|
133dc7956f | ||
|
|
8bc56095ed | ||
|
|
640df8608a | ||
|
|
317063754b | ||
|
|
588dbe510f | ||
|
|
b71877bb58 | ||
|
|
8c17cff3d4 | ||
|
|
bef4f37446 | ||
|
|
9c6dc098ce | ||
|
|
28a3fc49b5 | ||
|
|
cb43285e52 | ||
|
|
a09e37bcbb | ||
|
|
9b03530a21 | ||
|
|
2621ce491f | ||
|
|
e23915c501 | ||
|
|
755d4f2d39 | ||
|
|
e7b5fb824d | ||
|
|
0c1731136d | ||
|
|
efc7c4f339 | ||
|
|
512a6e4f9a | ||
|
|
968c2a7010 | ||
|
|
334da9ec46 | ||
|
|
df8a40f7e2 | ||
|
|
43762330cd | ||
|
|
0e812c0e4f | ||
|
|
8f8a85567f | ||
|
|
356e9706f2 | ||
|
|
3bb94559d6 | ||
|
|
4c560d0f41 | ||
|
|
8c4dbc0f71 | ||
|
|
ea5cb56e61 | ||
|
|
40f343e5d3 | ||
|
|
1404eb575f | ||
|
|
dba4dc632b |
27
.github/dependabot.yml
vendored
27
.github/dependabot.yml
vendored
@@ -7,7 +7,6 @@ registries:
|
||||
npm-npmjs:
|
||||
type: npm-registry
|
||||
url: https://registry.npmjs.org
|
||||
token: ${{secrets.NPM_NPMJS_TOKEN}}
|
||||
replaces-base: true
|
||||
|
||||
updates:
|
||||
@@ -15,9 +14,9 @@ updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
production:
|
||||
dependency-type: production
|
||||
@@ -37,9 +36,9 @@ updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
@@ -53,9 +52,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/macos
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -69,9 +68,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /apps/shared/MoltbotKit
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -85,9 +84,9 @@ updates:
|
||||
- package-ecosystem: swift
|
||||
directory: /Swabble
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
swift-deps:
|
||||
patterns:
|
||||
@@ -101,9 +100,9 @@ updates:
|
||||
- package-ecosystem: gradle
|
||||
directory: /apps/android
|
||||
schedule:
|
||||
interval: daily
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
android-deps:
|
||||
patterns:
|
||||
@@ -119,7 +118,7 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 2
|
||||
default-days: 7
|
||||
groups:
|
||||
docker-images:
|
||||
patterns:
|
||||
|
||||
127
.github/workflows/auto-response.yml
vendored
127
.github/workflows/auto-response.yml
vendored
@@ -3,8 +3,6 @@ name: Auto response
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
@@ -44,7 +42,6 @@ jobs:
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
commentTriggers: ["testflight"],
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
@@ -58,76 +55,11 @@ 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) {
|
||||
@@ -140,63 +72,6 @@ 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);
|
||||
@@ -219,6 +94,7 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
@@ -260,6 +136,7 @@ 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({
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -404,7 +404,6 @@ jobs:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 16 vCPU runner:
|
||||
|
||||
5
.github/workflows/install-smoke.yml
vendored
5
.github/workflows/install-smoke.yml
vendored
@@ -48,11 +48,6 @@ jobs:
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# 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).
|
||||
|
||||
123
CHANGELOG.md
123
CHANGELOG.md
@@ -2,129 +2,30 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.27
|
||||
## 2026.2.26 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram/Reply media context: include replied media files in inbound context when replying to media, defer reply-media downloads to debounce flush, gate reply-media fetch behind DM authorization, and preserve replied media when non-vision sticker fallback runs (including cached-sticker paths). (#28488) Thanks @obviyus.
|
||||
- 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/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
|
||||
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
|
||||
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
|
||||
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
|
||||
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### 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.
|
||||
- Auth/Onboarding: add an explicit account-risk warning and confirmation gate before starting Gemini CLI OAuth, and document the caution in provider docs and the Gemini CLI auth plugin README. (#16683) Thanks @vincentkoc.
|
||||
- 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.
|
||||
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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.
|
||||
- Gemini OAuth/Auth flow: align OAuth project discovery metadata and endpoint fallback handling for Gemini CLI auth, including fallback coverage for environment-provided project IDs. (#16684) Thanks @vincentkoc.
|
||||
- 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.
|
||||
- Config/Doctor allowlist safety: reject `dmPolicy: "allowlist"` configs with empty `allowFrom`, add Telegram account-level inheritance-aware validation, and teach `openclaw doctor --fix` to restore missing `allowFrom` entries from pairing-store files when present, preventing silent DM drops after upgrades. (#27936) Thanks @widingmarcus-cyber.
|
||||
- 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)
|
||||
- Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so `openclaw doctor` no longer reports false-positive transcript-missing warnings for `*:slash:*` keys. (#27375) thanks @gumadeiras.
|
||||
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
|
||||
- Telegram/Inline buttons: allow callback-query button handling in groups (including `/models` follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.
|
||||
- Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example `no` before `no problem`). (#27449) Thanks @emanuelst for the original fix direction in #19673.
|
||||
- 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)
|
||||
- Models/Google Antigravity IDs: normalize bare `gemini-3-pro`, `gemini-3.1-pro`, and `gemini-3-1-pro` model IDs to the default `-low` thinking tier so provider requests no longer fail with 404 when the tier suffix is omitted. (#24145) Thanks @byungsker.
|
||||
- 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/Google Gemini: treat `google` (Gemini API key auth profile) as a reasoning-tag provider to prevent `<think>` leakage, and add forward-compat model fallback for `google-gemini-cli` `gemini-3.1-pro*` / `gemini-3.1-flash*` IDs to avoid false unknown-model errors. (#26551, #26524) 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.
|
||||
- Memory/SQLite: deduplicate concurrent memory-manager initialization and auto-reopen stale SQLite handles after atomic reindex swaps, preventing repeated `attempt to write a readonly database` sync failures until gateway restart.
|
||||
- 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.
|
||||
- Agents/Models config: preserve agent-level provider `apiKey` and `baseUrl` during merge-mode `models.json` updates when agent values are present. (#27293) thanks @Sid-Qin.
|
||||
- Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.
|
||||
- Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. Thanks @gumadeiras.
|
||||
- Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Cron/CLI: add `--session` for session-key routing and rename target selection to `--session-target` for `openclaw cron add/edit`, including `--clear-session` on edit for unsetting the key. (#27167) thanks @Matt-Hulme.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
@@ -164,7 +65,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
|
||||
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)
|
||||
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007)
|
||||
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool ... not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.
|
||||
- Cron/Model overrides: when isolated `payload.model` is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.
|
||||
- Agents/Model fallback: keep explicit text + image fallback chains reachable even when `agents.defaults.models` allowlists are present, prefer explicit run `agentId` over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify `model_cooldown` / `cooling down` errors as `rate_limit` so failover continues. (#11972, #24137, #17231)
|
||||
- Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of `disabledReason` only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for `rate_limit`. (#23816) thanks @ramezgaberiel.
|
||||
@@ -186,10 +86,8 @@ Docs: https://docs.openclaw.ai
|
||||
- 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/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.
|
||||
@@ -306,7 +204,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
@@ -362,8 +259,6 @@ 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,9 +32,6 @@ 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)
|
||||
|
||||
@@ -53,15 +50,6 @@ 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)
|
||||
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman_](https://x.com/jlehman_)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
@@ -51,11 +51,6 @@ RUN pnpm build
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
|
||||
# Expose the CLI binary without requiring npm global writes as non-root.
|
||||
USER root
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Security hardening: Run as non-root user
|
||||
|
||||
@@ -41,7 +41,6 @@ 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`.
|
||||
|
||||
@@ -54,12 +53,10 @@ 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
|
||||
@@ -116,10 +113,8 @@ 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
|
||||
|
||||
@@ -155,7 +150,6 @@ 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
|
||||
|
||||
160
appcast.xml
160
appcast.xml
@@ -209,106 +209,84 @@
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.26</title>
|
||||
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
|
||||
<title>2026.2.25</title>
|
||||
<pubDate>Thu, 26 Feb 2026 05:14:17 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>15221</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
|
||||
<sparkle:version>14883</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.25</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.25</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
|
||||
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
|
||||
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> 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 <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
|
||||
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
|
||||
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
|
||||
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
|
||||
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
|
||||
<li>Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.</li>
|
||||
<li>Android/Startup perf: defer foreground-service startup, move WebView debugging init out of critical startup, and add startup macrobenchmark + low-noise perf CLI scripts for deterministic cold-start tracking. (#26659) Thanks @obviyus.</li>
|
||||
<li>UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.</li>
|
||||
<li>Heartbeat/Config: replace heartbeat DM toggle with <code>agents.defaults.heartbeat.directPolicy</code> (<code>allow</code> | <code>block</code>; also supported per-agent via <code>agents.list[].heartbeat.directPolicy</code>) for clearer delivery semantics.</li>
|
||||
<li>Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.</li>
|
||||
<li>Branding/Docs + Apple surfaces: replace remaining <code>bot.molt</code> launchd label, bundle-id, logging subsystem, and command examples with <code>ai.openclaw</code> across docs, iOS app surfaces, helper scripts, and CLI test fixtures.</li>
|
||||
<li>Agents/Config: remind agents to call <code>config.schema</code> before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.</li>
|
||||
<li>Dependencies: update workspace dependency pins and lockfile (Bedrock SDK <code>3.998.0</code>, <code>@mariozechner/pi-*</code> <code>0.55.1</code>, TypeScript native preview <code>7.0.0-dev.20260225.1</code>) while keeping <code>@buape/carbon</code> pinned.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Heartbeat direct/DM delivery default is now <code>allow</code> again. To keep DM-blocked behavior from <code>2026.2.24</code>, set <code>agents.defaults.heartbeat.directPolicy: "block"</code> (or per-agent override).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
|
||||
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> 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.</li>
|
||||
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
|
||||
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
|
||||
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> 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.</li>
|
||||
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
|
||||
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
|
||||
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
|
||||
<li>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)</li>
|
||||
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> 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.</li>
|
||||
<li>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.</li>
|
||||
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
|
||||
<li>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)</li>
|
||||
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
|
||||
<li>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)</li>
|
||||
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> 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.</li>
|
||||
<li>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 <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
|
||||
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> 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)</li>
|
||||
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) 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.</li>
|
||||
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
|
||||
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
|
||||
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
|
||||
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
|
||||
<li>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 <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
|
||||
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> 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)</li>
|
||||
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
|
||||
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
|
||||
<li>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.</li>
|
||||
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
|
||||
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> 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.</li>
|
||||
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> 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.</li>
|
||||
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
|
||||
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
|
||||
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> 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.</li>
|
||||
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
|
||||
<li>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.</li>
|
||||
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
|
||||
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
|
||||
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
|
||||
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
|
||||
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
|
||||
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
|
||||
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
|
||||
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> 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)</li>
|
||||
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
|
||||
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
|
||||
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
|
||||
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
|
||||
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
|
||||
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
|
||||
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> 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 (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
|
||||
<li>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 <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Config includes: harden <code>$include</code> 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 (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, 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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> 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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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.</li>
|
||||
<li>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 (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
|
||||
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> 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)</li>
|
||||
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
|
||||
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
|
||||
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> 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.</li>
|
||||
<li>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)</li>
|
||||
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
|
||||
<li>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 <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
|
||||
<li>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.</li>
|
||||
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</li>
|
||||
<li>Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without <code>message_id</code> as delivery failures (instead of false-success <code>"unknown"</code> IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.</li>
|
||||
<li>Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156)</li>
|
||||
<li>Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable <code>session.parentForkMaxTokens</code> (default <code>100000</code>, <code>0</code> disables). (#26912) Thanks @markshields-tl.</li>
|
||||
<li>Cron/Message multi-account routing: honor explicit <code>delivery.accountId</code> for isolated cron delivery resolution, and when <code>message.send</code> omits <code>accountId</code>, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.</li>
|
||||
<li>Gateway/Message media roots: thread <code>agentId</code> through gateway <code>send</code> RPC and prefer explicit <code>agentId</code> over session/default resolution so non-default agent workspace media sends no longer fail with <code>LocalMediaAccessError</code>; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.</li>
|
||||
<li>Followups/Routing: when explicit origin routing fails, allow same-channel fallback dispatch (while still blocking cross-channel fallback) so followup replies do not get dropped on transient origin-adapter failures. (#26109) Thanks @Sid-Qin.</li>
|
||||
<li>Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed <code>delivered</code>, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)</li>
|
||||
<li>LINE/Lifecycle: keep LINE <code>startAccount</code> pending until abort so webhook startup is no longer misread as immediate channel exit, preventing restart-loop storms on LINE provider boot. (#26528) Thanks @Sid-Qin.</li>
|
||||
<li>Discord/Gateway: capture and drain startup-time gateway <code>error</code> events before lifecycle listeners attach so early <code>Fatal Gateway error: 4014</code> closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.</li>
|
||||
<li>Discord/Inbound text: preserve embed <code>title</code> + <code>description</code> fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.</li>
|
||||
<li>Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to <code>file</code> so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.</li>
|
||||
<li>Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)</li>
|
||||
<li>Telegram/Markdown spoilers: keep valid <code>||spoiler||</code> pairs while leaving unmatched trailing <code>||</code> delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.</li>
|
||||
<li>Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example <code>c0abc12345</code>) correctly match Slack runtime IDs (<code>C0ABC12345</code>) under <code>groupPolicy: "allowlist"</code>, preventing silent channel-event drops. (#26878) Thanks @lbo728.</li>
|
||||
<li>Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.</li>
|
||||
<li>Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.</li>
|
||||
<li>Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including <code>NO_REPLY</code>, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.</li>
|
||||
<li>Voice-call/TTS tools: hide the <code>tts</code> tool when the message provider is <code>voice</code>, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025)</li>
|
||||
<li>Agents/Tools: normalize non-standard plugin tool results that omit <code>content</code> so embedded runs no longer crash with <code>Cannot read properties of undefined (reading 'filter')</code> after tool completion (including <code>tesseramemo_query</code>). (#27007)</li>
|
||||
<li>Cron/Model overrides: when isolated <code>payload.model</code> is no longer allowlisted, fall back to default model selection instead of failing the job, while still returning explicit errors for invalid model strings. (#26717) Thanks @Youyou972.</li>
|
||||
<li>Agents/Model fallback: keep explicit text + image fallback chains reachable even when <code>agents.defaults.models</code> allowlists are present, prefer explicit run <code>agentId</code> over session-key parsing for followup fallback override resolution (with session-key fallback), treat agent-level fallback overrides as configured in embedded runner preflight, and classify <code>model_cooldown</code> / <code>cooling down</code> errors as <code>rate_limit</code> so failover continues. (#11972, #24137, #17231)</li>
|
||||
<li>Agents/Model fallback: keep same-provider fallback chains active when session model differs from configured primary, infer cooldown reason from provider profile state (instead of <code>disabledReason</code> only), keep no-profile fallback providers eligible (env/models.json paths), and only relax same-provider cooldown fallback attempts for <code>rate_limit</code>. (#23816) thanks @ramezgaberiel.</li>
|
||||
<li>Agents/Model fallback: continue fallback traversal on unrecognized errors when candidates remain, while still throwing the original unknown error on the last candidate. (#26106) Thanks @Sid-Qin.</li>
|
||||
<li>Models/Auth probes: map permanent auth failover reasons (<code>auth_permanent</code>, for example revoked keys) into probe auth status instead of <code>unknown</code>, so <code>openclaw models status --probe</code> reports actionable auth failures. (#25754) thanks @rrenamed.</li>
|
||||
<li>Hooks/Inbound metadata: include <code>guildId</code> and <code>channelName</code> in <code>message_received</code> metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.</li>
|
||||
<li>Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get <code>CommandAuthorized: true</code> on modal/button events. (#26119) Thanks @bmendonca3.</li>
|
||||
<li>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.</li>
|
||||
<li>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 (<code>2026.2.25</code>). Thanks @luz-oasis for reporting.</li>
|
||||
<li>Security/Gateway trusted proxy: require <code>operator</code> role for the Control UI trusted-proxy pairing bypass so unpaired <code>node</code> sessions can no longer connect via <code>client.id=control-ui</code> and invoke node event methods. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy <code>oauth.json</code> onboarding path that exposed the PKCE verifier via OAuth <code>state</code>; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (<code>2026.2.25</code>). Thanks @zdi-disclosures for reporting.</li>
|
||||
<li>Security/Microsoft Teams file consent: bind <code>fileConsent/invoke</code> upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked <code>uploadId</code> values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Gateway: harden <code>agents.files</code> path handling to block out-of-workspace symlink targets for <code>agents.files.get</code>/<code>agents.files.set</code>, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Workspace FS: reject hardlinked workspace file aliases in <code>tools.fs.workspaceOnly</code> and <code>tools.exec.applyPatch.workspaceOnly</code> 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 (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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 (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before <code>setFiles</code>, with regression coverage for strict missing-path handling.</li>
|
||||
<li>Security/Exec approvals: bind <code>system.run</code> 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 (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals: harden approval-bound <code>system.run</code> execution on node hosts by rejecting symlink <code>cwd</code> 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 (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under <code>dmPolicy</code>/<code>groupPolicy</code>; reaction notifications now require channel access checks first. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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 <code>groupPolicy</code> channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Slack reactions + pins: gate <code>reaction_*</code> and <code>pin_*</code> system-event enqueue through shared sender authorization so DM <code>dmPolicy</code>/<code>allowFrom</code> and channel <code>users</code> allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Telegram reactions: enforce <code>dmPolicy</code>/<code>allowFrom</code> and group allowlist authorization on <code>message_reaction</code> events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Slack interactions: enforce channel/DM authorization and modal actor binding (<code>private_metadata.userId</code>) before enqueueing <code>block_action</code>/<code>view_submission</code>/<code>view_closed</code> system events, with regression coverage for unauthorized senders and missing/mismatched actor metadata. This ships in the next npm release (<code>2026.2.25</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>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.</li>
|
||||
<li>Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.</li>
|
||||
<li>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.</li>
|
||||
<li>Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.</li>
|
||||
<li>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.</li>
|
||||
<li>Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.</li>
|
||||
<li>Security/SSRF guard: classify IPv6 multicast literals (<code>ff00::/8</code>) 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 (<code>2026.2.25</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Tests/Low-memory stability: disable Vitest <code>vmForks</code> by default on low-memory local hosts (<code><64 GiB</code>), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with <code>setSessionRuntimeModel</code> usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.25/OpenClaw-2026.2.25.zip" length="23078398" type="application/octet-stream" sparkle:edSignature="PJjvRhivhybV5bYr8u1C9Dyw4h8yePGwG8SFsr4QRqMSBYMEedraPJO3KNbkoChjclYUYf3oGcC4daNZnFvgBA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -150,56 +150,6 @@ More details: `docs/platforms/android.md`.
|
||||
- `CAMERA` for `camera.snap` and `camera.clip`
|
||||
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
|
||||
|
||||
## Integration Capability Test (Preconditioned)
|
||||
|
||||
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.
|
||||
|
||||
Pre-req checklist:
|
||||
|
||||
1) Gateway is running and reachable from the Android app.
|
||||
2) Android app is connected to that gateway and `openclaw nodes status` shows it as paired + connected.
|
||||
3) App stays unlocked and in foreground for the whole run.
|
||||
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
|
||||
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
|
||||
6) No interactive system dialogs should be pending before test start.
|
||||
7) Canvas host is enabled and reachable from the device (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
|
||||
8) Local operator test client pairing is approved. If first run fails with `pairing required`, approve latest pending device pairing request, then rerun:
|
||||
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw devices approve --latest
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm android:test:integration
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
- `OPENCLAW_ANDROID_GATEWAY_URL=ws://...` (default: from your local OpenClaw config)
|
||||
- `OPENCLAW_ANDROID_GATEWAY_TOKEN=...`
|
||||
- `OPENCLAW_ANDROID_GATEWAY_PASSWORD=...`
|
||||
- `OPENCLAW_ANDROID_NODE_ID=...` or `OPENCLAW_ANDROID_NODE_NAME=...`
|
||||
|
||||
What it does:
|
||||
|
||||
- Reads `node.describe` command list from the selected Android node.
|
||||
- Invokes advertised non-interactive commands.
|
||||
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
|
||||
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
|
||||
|
||||
Common failure quick-fixes:
|
||||
|
||||
- `pairing required` before tests start:
|
||||
- approve pending device pairing (`openclaw devices approve --latest`) and rerun.
|
||||
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
|
||||
- ensure gateway canvas host is running and reachable, keep the app on the **Screen** tab. The app will auto-refresh canvas capability once; if it still fails, reconnect app and rerun.
|
||||
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
|
||||
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
|
||||
|
||||
## Contributions
|
||||
|
||||
This Android app is currently being rebuilt.
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602270
|
||||
versionName = "2026.2.27"
|
||||
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")
|
||||
|
||||
@@ -65,6 +65,8 @@ class NodeRuntime(context: Context) {
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
prefs = prefs,
|
||||
connectedEndpoint = { connectedEndpoint },
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
triggerCameraFlash = ::triggerCameraFlash,
|
||||
@@ -90,10 +92,6 @@ class NodeRuntime(context: Context) {
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
@@ -129,7 +127,6 @@ class NodeRuntime(context: Context) {
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
@@ -141,7 +138,6 @@ class NodeRuntime(context: Context) {
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -173,47 +173,6 @@ class GatewaySession(
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun refreshNodeCanvasCapability(timeoutMs: Long = 8_000): Boolean {
|
||||
val conn = currentConnection ?: return false
|
||||
val response =
|
||||
try {
|
||||
conn.request(
|
||||
"node.canvas.capability.refresh",
|
||||
params = buildJsonObject {},
|
||||
timeoutMs = timeoutMs,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh failed: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
if (!response.ok) {
|
||||
val err = response.error
|
||||
Log.w(
|
||||
"OpenClawGateway",
|
||||
"node.canvas.capability.refresh rejected: ${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}",
|
||||
)
|
||||
return false
|
||||
}
|
||||
val payloadObj = response.payloadJson?.let(::parseJsonOrNull)?.asObjectOrNull()
|
||||
val refreshedCapability = payloadObj?.get("canvasCapability").asStringOrNull()?.trim().orEmpty()
|
||||
if (refreshedCapability.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing canvasCapability")
|
||||
return false
|
||||
}
|
||||
val scopedCanvasHostUrl = canvasHostUrl?.trim().orEmpty()
|
||||
if (scopedCanvasHostUrl.isEmpty()) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh missing local canvasHostUrl")
|
||||
return false
|
||||
}
|
||||
val refreshedUrl = replaceCanvasCapabilityInScopedHostUrl(scopedCanvasHostUrl, refreshedCapability)
|
||||
if (refreshedUrl == null) {
|
||||
Log.w("OpenClawGateway", "node.canvas.capability.refresh unable to rewrite scoped canvas URL")
|
||||
return false
|
||||
}
|
||||
canvasHostUrl = refreshedUrl
|
||||
return true
|
||||
}
|
||||
|
||||
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private inner class Connection(
|
||||
@@ -413,7 +372,7 @@ class GatewaySession(
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
DeviceAuthPayload.buildV3(
|
||||
buildDeviceAuthPayload(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
@@ -422,8 +381,6 @@ 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)
|
||||
@@ -542,16 +499,11 @@ class GatewaySession(
|
||||
} catch (err: Throwable) {
|
||||
invokeErrorFromThrowable(err)
|
||||
}
|
||||
sendInvokeResult(id, nodeId, result, timeoutMs)
|
||||
sendInvokeResult(id, nodeId, result)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendInvokeResult(
|
||||
id: String,
|
||||
nodeId: String,
|
||||
result: InvokeResult,
|
||||
invokeTimeoutMs: Long?,
|
||||
) {
|
||||
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
||||
val parsedPayload = result.payloadJson?.let { parseJsonOrNull(it) }
|
||||
val params =
|
||||
buildJsonObject {
|
||||
@@ -573,14 +525,10 @@ class GatewaySession(
|
||||
)
|
||||
}
|
||||
}
|
||||
val ackTimeoutMs = resolveInvokeResultAckTimeoutMs(invokeTimeoutMs)
|
||||
try {
|
||||
request("node.invoke.result", params, timeoutMs = ackTimeoutMs)
|
||||
request("node.invoke.result", params, timeoutMs = 15_000)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(
|
||||
loggerTag,
|
||||
"node.invoke.result failed (ackTimeoutMs=$ackTimeoutMs): ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +582,33 @@ 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,
|
||||
@@ -737,24 +712,3 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun replaceCanvasCapabilityInScopedHostUrl(
|
||||
scopedUrl: String,
|
||||
capability: String,
|
||||
): String? {
|
||||
val marker = "/__openclaw__/cap/"
|
||||
val markerStart = scopedUrl.indexOf(marker)
|
||||
if (markerStart < 0) return null
|
||||
val capabilityStart = markerStart + marker.length
|
||||
val slashEnd = scopedUrl.indexOf("/", capabilityStart).takeIf { it >= 0 }
|
||||
val queryEnd = scopedUrl.indexOf("?", capabilityStart).takeIf { it >= 0 }
|
||||
val fragmentEnd = scopedUrl.indexOf("#", capabilityStart).takeIf { it >= 0 }
|
||||
val capabilityEnd = listOfNotNull(slashEnd, queryEnd, fragmentEnd).minOrNull() ?: scopedUrl.length
|
||||
if (capabilityEnd <= capabilityStart) return null
|
||||
return scopedUrl.substring(0, capabilityStart) + capability + scopedUrl.substring(capabilityEnd)
|
||||
}
|
||||
|
||||
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
|
||||
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
|
||||
return normalized.coerceIn(15_000L, 120_000L)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.util.Base64
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.CameraInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
@@ -33,10 +30,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
@@ -47,12 +40,6 @@ import kotlin.coroutines.resumeWithException
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
|
||||
data class CameraDeviceInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val position: String,
|
||||
val deviceType: String,
|
||||
)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
@@ -65,14 +52,6 @@ class CameraCaptureManager(private val context: Context) {
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
suspend fun listDevices(): List<CameraDeviceInfo> =
|
||||
withContext(Dispatchers.Main) {
|
||||
val provider = context.cameraProvider()
|
||||
provider.availableCameraInfos
|
||||
.mapNotNull { info -> cameraDeviceInfoOrNull(info) }
|
||||
.sortedBy { it.id }
|
||||
}
|
||||
|
||||
private suspend fun ensureCameraPermission() {
|
||||
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) return
|
||||
@@ -101,15 +80,14 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
val deviceId = parseDeviceId(params)
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
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()
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
@@ -167,14 +145,12 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val deviceId = parseDeviceId(params)
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio deviceId=${deviceId ?: "-"}")
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio")
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
|
||||
@@ -186,7 +162,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
)
|
||||
.build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
// CameraX requires a Preview use case for the camera to start producing frames;
|
||||
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
|
||||
@@ -293,104 +270,49 @@ class CameraCaptureManager(private val context: Context) {
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
private fun parseFacing(paramsJson: String?): String? =
|
||||
when {
|
||||
paramsJson?.contains("\"front\"") == true -> "front"
|
||||
paramsJson?.contains("\"back\"") == true -> "back"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
private fun parseQuality(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "quality")?.toDoubleOrNull()
|
||||
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
private fun parseMaxWidth(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "maxWidth")?.toIntOrNull()
|
||||
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "maxWidth")
|
||||
?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
readPrimitive(params, "deviceId")
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' }
|
||||
}
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
private fun resolveCameraSelector(
|
||||
provider: ProcessCameraProvider,
|
||||
facing: String,
|
||||
deviceId: String?,
|
||||
): CameraSelector {
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
return if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
}
|
||||
val availableIds = provider.availableCameraInfos.mapNotNull { cameraIdOrNull(it) }.toSet()
|
||||
if (!availableIds.contains(deviceId)) {
|
||||
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
|
||||
}
|
||||
return CameraSelector.Builder()
|
||||
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
|
||||
val cameraId = cameraIdOrNull(info) ?: return null
|
||||
val lensFacing =
|
||||
runCatching {
|
||||
Camera2CameraInfo.from(info).getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
|
||||
}.getOrNull()
|
||||
val position =
|
||||
when (lensFacing) {
|
||||
CameraCharacteristics.LENS_FACING_FRONT -> "front"
|
||||
CameraCharacteristics.LENS_FACING_BACK -> "back"
|
||||
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
|
||||
else -> "unspecified"
|
||||
}
|
||||
val deviceType =
|
||||
if (lensFacing == CameraCharacteristics.LENS_FACING_EXTERNAL) "external" else "builtIn"
|
||||
val name =
|
||||
when (position) {
|
||||
"front" -> "Front Camera"
|
||||
"back" -> "Back Camera"
|
||||
"external" -> "External Camera"
|
||||
else -> "Camera $cameraId"
|
||||
}
|
||||
return CameraDeviceInfo(
|
||||
id = cameraId,
|
||||
name = name,
|
||||
position = position,
|
||||
deviceType = deviceType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cameraIdOrNull(info: CameraInfo): String? =
|
||||
runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
|
||||
@@ -3,57 +3,25 @@ package ai.openclaw.android.node
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.CameraHudKind
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean =
|
||||
rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
private val prefs: SecurePrefs,
|
||||
private val connectedEndpoint: () -> GatewayEndpoint?,
|
||||
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
|
||||
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
|
||||
private val triggerCameraFlash: () -> Unit,
|
||||
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
|
||||
) {
|
||||
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
val devices = camera.listDevices()
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"devices",
|
||||
buildJsonArray {
|
||||
devices.forEach { device ->
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", JsonPrimitive(device.id))
|
||||
put("name", JsonPrimitive(device.name))
|
||||
put("position", JsonPrimitive(device.position))
|
||||
put("deviceType", JsonPrimitive(device.deviceType))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
GatewaySession.InvokeResult.ok(payload)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
@@ -101,7 +69,7 @@ class CameraHandler(
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
}
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
@@ -121,28 +89,62 @@ class CameraHandler(
|
||||
showCameraHud(message, CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
val rawBytes = filePayload.file.length()
|
||||
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
|
||||
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
|
||||
withContext(Dispatchers.IO) { filePayload.file.delete() }
|
||||
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "PAYLOAD_TOO_LARGE",
|
||||
message =
|
||||
"PAYLOAD_TOO_LARGE: camera clip is $rawBytes bytes; max is $CAMERA_CLIP_MAX_RAW_BYTES bytes. Reduce durationMs and retry.",
|
||||
// Upload file via HTTP instead of base64 through WebSocket
|
||||
clipLog("uploading via HTTP...")
|
||||
val uploadUrl = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ep = connectedEndpoint()
|
||||
val gatewayHost = if (ep != null) {
|
||||
val isHttps = ep.tlsEnabled || ep.port == 443
|
||||
if (!isHttps) {
|
||||
clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64")
|
||||
throw Exception("HTTPS required for upload (bearer token protection)")
|
||||
}
|
||||
if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}"
|
||||
} else {
|
||||
clipLog("error: no gateway endpoint connected, cannot upload")
|
||||
throw Exception("no gateway endpoint connected")
|
||||
}
|
||||
val token = prefs.loadGatewayToken() ?: ""
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val body = filePayload.file.asRequestBody("video/mp4".toMediaType())
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url("$gatewayHost/upload/clip.mp4")
|
||||
.put(body)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4")
|
||||
val resp = client.newCall(req).execute()
|
||||
val respBody = resp.body?.string() ?: ""
|
||||
clipLog("upload response: ${resp.code} $respBody")
|
||||
filePayload.file.delete()
|
||||
if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}")
|
||||
// Parse URL from response
|
||||
val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody)
|
||||
urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody")
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clipLog("upload failed: ${err.message}, falling back to base64")
|
||||
// Fallback to base64 if upload fails
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
}
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
clipLog("returning base64 payload")
|
||||
clipLog("returning URL result: $uploadUrl")
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
"""{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
@@ -152,24 +154,4 @@ class CameraHandler(
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
val root =
|
||||
try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
val value =
|
||||
(root["includeAudio"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ class ConnectionManager(
|
||||
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()) {
|
||||
|
||||
@@ -62,8 +62,7 @@ class DebugHandler(
|
||||
results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
val diagnostics = results.joinToString("\n")
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":${JsonPrimitive(diagnostics)}}""")
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""")
|
||||
} catch (e: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
|
||||
}
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
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 androidx.core.content.ContextCompat
|
||||
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,
|
||||
) {
|
||||
private data class BatterySnapshot(
|
||||
val status: Int,
|
||||
val plugged: Int,
|
||||
val levelFraction: Double?,
|
||||
val temperatureC: Double?,
|
||||
)
|
||||
|
||||
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(statusPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(infoPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(permissionsPayloadJson())
|
||||
}
|
||||
|
||||
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
return GatewaySession.InvokeResult.ok(healthPayloadJson())
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
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 {
|
||||
battery.levelFraction?.let { put("level", JsonPrimitive(it)) }
|
||||
put("state", JsonPrimitive(mapBatteryState(battery.status)))
|
||||
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 permissionsPayloadJson(): String {
|
||||
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
|
||||
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"permissions",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"camera",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.CAMERA),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"microphone",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.RECORD_AUDIO),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"location",
|
||||
permissionStateJson(
|
||||
granted =
|
||||
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"backgroundLocation",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"sms",
|
||||
permissionStateJson(
|
||||
granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
|
||||
promptableWhenDenied = canSendSms,
|
||||
),
|
||||
)
|
||||
put(
|
||||
"notificationListener",
|
||||
permissionStateJson(
|
||||
granted = notificationAccess,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
|
||||
put(
|
||||
"screenCapture",
|
||||
permissionStateJson(
|
||||
granted = false,
|
||||
promptableWhenDenied = true,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun healthPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
val batteryManager = appContext.getSystemService(BatteryManager::class.java)
|
||||
val currentNowUa = batteryManager?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
|
||||
val currentNowMa =
|
||||
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
|
||||
null
|
||||
} else {
|
||||
currentNowUa.toDouble() / 1_000.0
|
||||
}
|
||||
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
val activityManager = appContext.getSystemService(ActivityManager::class.java)
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager?.getMemoryInfo(memoryInfo)
|
||||
val totalRamBytes = memoryInfo.totalMem.coerceAtLeast(0L)
|
||||
val availableRamBytes = memoryInfo.availMem.coerceAtLeast(0L)
|
||||
val usedRamBytes = (totalRamBytes - availableRamBytes).coerceAtLeast(0L)
|
||||
val lowMemory = memoryInfo.lowMemory
|
||||
val memoryPressure = mapMemoryPressure(totalRamBytes, availableRamBytes, lowMemory)
|
||||
|
||||
return buildJsonObject {
|
||||
put(
|
||||
"memory",
|
||||
buildJsonObject {
|
||||
put("pressure", JsonPrimitive(memoryPressure))
|
||||
put("totalRamBytes", JsonPrimitive(totalRamBytes))
|
||||
put("availableRamBytes", JsonPrimitive(availableRamBytes))
|
||||
put("usedRamBytes", JsonPrimitive(usedRamBytes))
|
||||
put("thresholdBytes", JsonPrimitive(memoryInfo.threshold.coerceAtLeast(0L)))
|
||||
put("lowMemory", JsonPrimitive(lowMemory))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"battery",
|
||||
buildJsonObject {
|
||||
put("state", JsonPrimitive(mapBatteryState(battery.status)))
|
||||
put("chargingType", JsonPrimitive(mapChargingType(battery.plugged)))
|
||||
battery.temperatureC?.let { put("temperatureC", JsonPrimitive(it)) }
|
||||
currentNowMa?.let { put("currentMa", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
put(
|
||||
"power",
|
||||
buildJsonObject {
|
||||
put("dozeModeEnabled", JsonPrimitive(powerManager?.isDeviceIdleMode == true))
|
||||
put("lowPowerModeEnabled", JsonPrimitive(powerManager?.isPowerSaveMode == true))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"system",
|
||||
buildJsonObject {
|
||||
Build.VERSION.SECURITY_PATCH
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { put("securityPatchLevel", JsonPrimitive(it)) }
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun readBatterySnapshot(): BatterySnapshot {
|
||||
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val status =
|
||||
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
?: BatteryManager.BATTERY_STATUS_UNKNOWN
|
||||
val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0
|
||||
val temperatureC =
|
||||
intent
|
||||
?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
|
||||
?.takeIf { it != Int.MIN_VALUE }
|
||||
?.toDouble()
|
||||
?.div(10.0)
|
||||
return BatterySnapshot(
|
||||
status = status,
|
||||
plugged = plugged,
|
||||
levelFraction = batteryLevelFraction(intent),
|
||||
temperatureC = temperatureC,
|
||||
)
|
||||
}
|
||||
|
||||
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 mapChargingType(plugged: Int): String {
|
||||
return when (plugged) {
|
||||
BatteryManager.BATTERY_PLUGGED_AC -> "ac"
|
||||
BatteryManager.BATTERY_PLUGGED_USB -> "usb"
|
||||
BatteryManager.BATTERY_PLUGGED_WIRELESS -> "wireless"
|
||||
BatteryManager.BATTERY_PLUGGED_DOCK -> "dock"
|
||||
else -> "none"
|
||||
}
|
||||
}
|
||||
|
||||
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 permissionStateJson(granted: Boolean, promptableWhenDenied: Boolean) =
|
||||
buildJsonObject {
|
||||
put("status", JsonPrimitive(if (granted) "granted" else "denied"))
|
||||
put("promptable", JsonPrimitive(!granted && promptableWhenDenied))
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapMemoryPressure(totalBytes: Long, availableBytes: Long, lowMemory: Boolean): String {
|
||||
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
|
||||
if (lowMemory) return "critical"
|
||||
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
|
||||
return when {
|
||||
freeRatio <= 0.05 -> "critical"
|
||||
freeRatio <= 0.15 -> "high"
|
||||
freeRatio <= 0.30 -> "moderate"
|
||||
else -> "normal"
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package ai.openclaw.android.node
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.RemoteInput
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
@@ -36,28 +34,6 @@ data class DeviceNotificationSnapshot(
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
enum class NotificationActionKind {
|
||||
Open,
|
||||
Dismiss,
|
||||
Reply,
|
||||
}
|
||||
|
||||
data class NotificationActionRequest(
|
||||
val key: String,
|
||||
val kind: NotificationActionKind,
|
||||
val replyText: String? = null,
|
||||
)
|
||||
|
||||
data class NotificationActionResult(
|
||||
val ok: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean {
|
||||
return kind == NotificationActionKind.Dismiss
|
||||
}
|
||||
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
@@ -109,26 +85,15 @@ private object DeviceNotificationStore {
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
activeService = this
|
||||
DeviceNotificationStore.setConnected(true)
|
||||
refreshActiveNotifications()
|
||||
}
|
||||
|
||||
override fun onListenerDisconnected() {
|
||||
if (activeService === this) {
|
||||
activeService = null
|
||||
}
|
||||
DeviceNotificationStore.setConnected(false)
|
||||
super.onListenerDisconnected()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (activeService === this) {
|
||||
activeService = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
val entry = sbn?.toEntry() ?: return
|
||||
@@ -174,8 +139,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
@@ -197,119 +160,5 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
|
||||
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
if (!isAccessEnabled(context)) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATIONS_DISABLED",
|
||||
message = "NOTIFICATIONS_DISABLED: enable notification access in system Settings",
|
||||
)
|
||||
}
|
||||
val service = activeService
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATIONS_UNAVAILABLE",
|
||||
message = "NOTIFICATIONS_UNAVAILABLE: notification listener not connected",
|
||||
)
|
||||
return service.executeActionInternal(request)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeActionInternal(request: NotificationActionRequest): NotificationActionResult {
|
||||
val sbn =
|
||||
activeNotifications
|
||||
?.firstOrNull { it.key == request.key }
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_FOUND",
|
||||
message = "NOTIFICATION_NOT_FOUND: notification key not found",
|
||||
)
|
||||
if (actionRequiresClearableNotification(request.kind) && !sbn.isClearable) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_CLEARABLE",
|
||||
message = "NOTIFICATION_NOT_CLEARABLE: notification is ongoing or protected",
|
||||
)
|
||||
}
|
||||
|
||||
return when (request.kind) {
|
||||
NotificationActionKind.Open -> {
|
||||
val pendingIntent = sbn.notification.contentIntent
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_UNAVAILABLE",
|
||||
message = "ACTION_UNAVAILABLE: notification has no open action",
|
||||
)
|
||||
runCatching {
|
||||
pendingIntent.send()
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "open failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
NotificationActionKind.Dismiss -> {
|
||||
runCatching {
|
||||
cancelNotification(sbn.key)
|
||||
DeviceNotificationStore.remove(sbn.key)
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "dismiss failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
NotificationActionKind.Reply -> {
|
||||
val replyText = request.replyText?.trim().orEmpty()
|
||||
if (replyText.isEmpty()) {
|
||||
return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: replyText required for reply action",
|
||||
)
|
||||
}
|
||||
val action =
|
||||
sbn.notification.actions
|
||||
?.firstOrNull { candidate ->
|
||||
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
|
||||
}
|
||||
?: return NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_UNAVAILABLE",
|
||||
message = "ACTION_UNAVAILABLE: notification has no reply action",
|
||||
)
|
||||
val remoteInputs = action.remoteInputs ?: emptyArray()
|
||||
val fillInIntent = Intent()
|
||||
val replyBundle = android.os.Bundle()
|
||||
for (remoteInput in remoteInputs) {
|
||||
replyBundle.putCharSequence(remoteInput.resultKey, replyText)
|
||||
}
|
||||
RemoteInput.addResultsToIntent(remoteInputs, fillInIntent, replyBundle)
|
||||
runCatching {
|
||||
action.actionIntent.send(this, 0, fillInIntent)
|
||||
}.fold(
|
||||
onSuccess = { NotificationActionResult(ok = true) },
|
||||
onFailure = { err ->
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "ACTION_FAILED",
|
||||
message = "ACTION_FAILED: ${err.message ?: "reply failed"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -62,11 +61,6 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.List.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
@@ -81,24 +75,9 @@ object InvokeCommandRegistry {
|
||||
name = OpenClawLocationCommand.Get.rawValue,
|
||||
availability = InvokeCommandAvailability.LocationEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Status.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Info.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Permissions.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Health.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.Actions.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawSmsCommand.Send.rawValue,
|
||||
availability = InvokeCommandAvailability.SmsAvailable,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -14,7 +13,6 @@ 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,
|
||||
@@ -26,7 +24,6 @@ class InvokeDispatcher(
|
||||
private val locationEnabled: () -> Boolean,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val refreshNodeCanvasCapability: suspend () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
) {
|
||||
@@ -113,22 +110,14 @@ class InvokeDispatcher(
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
OpenClawCameraCommand.List.rawValue -> cameraHandler.handleList(paramsJson)
|
||||
OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson)
|
||||
OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson)
|
||||
|
||||
// Location command
|
||||
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
|
||||
|
||||
// Device commands
|
||||
OpenClawDeviceCommand.Status.rawValue -> deviceHandler.handleDeviceStatus(paramsJson)
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
|
||||
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
OpenClawNotificationsCommand.Actions.rawValue -> notificationsHandler.handleNotificationsActions(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
@@ -150,30 +139,17 @@ class InvokeDispatcher(
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
var a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
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 readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!readyOnFirstCheck) {
|
||||
if (!refreshNodeCanvasCapability()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
@@ -2,20 +2,15 @@ package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
fun requestServiceRebind(context: Context)
|
||||
|
||||
fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
@@ -34,10 +29,6 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
|
||||
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
return DeviceNotificationListenerService.executeAction(context, request)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
@@ -47,80 +38,11 @@ class NotificationsHandler private constructor(
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = readSnapshotWithRebind()
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
readSnapshotWithRebind()
|
||||
|
||||
val params = parseParamsObject(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: expected JSON object",
|
||||
)
|
||||
val key =
|
||||
readString(params, "key")
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: key required",
|
||||
)
|
||||
val actionRaw =
|
||||
readString(params, "action")?.lowercase()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
|
||||
)
|
||||
val action =
|
||||
when (actionRaw) {
|
||||
"open" -> NotificationActionKind.Open
|
||||
"dismiss" -> NotificationActionKind.Dismiss
|
||||
"reply" -> NotificationActionKind.Reply
|
||||
else ->
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action must be open|dismiss|reply",
|
||||
)
|
||||
}
|
||||
val replyText = readString(params, "replyText")
|
||||
if (action == NotificationActionKind.Reply && replyText.isNullOrBlank()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: replyText required for reply action",
|
||||
)
|
||||
}
|
||||
|
||||
val result =
|
||||
stateProvider.executeAction(
|
||||
appContext,
|
||||
NotificationActionRequest(
|
||||
key = key,
|
||||
kind = action,
|
||||
replyText = replyText,
|
||||
),
|
||||
)
|
||||
if (!result.ok) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = result.code ?: "UNAVAILABLE",
|
||||
message = result.message ?: "notification action failed",
|
||||
)
|
||||
}
|
||||
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("ok", JsonPrimitive(true))
|
||||
put("key", JsonPrimitive(key))
|
||||
put("action", JsonPrimitive(actionRaw))
|
||||
}.toString()
|
||||
return GatewaySession.InvokeResult.ok(payload)
|
||||
}
|
||||
|
||||
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return snapshot
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
@@ -150,21 +72,6 @@ class NotificationsHandler private constructor(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readString(params: JsonObject, key: String): String? =
|
||||
(params[key] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
|
||||
@@ -10,10 +10,6 @@ import ai.openclaw.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -39,13 +35,12 @@ class ScreenRecordManager(private val context: Context) {
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val durationMs = (parseDurationMs(paramsJson) ?: 10_000).coerceIn(250, 60_000)
|
||||
val fps = (parseFps(paramsJson) ?: 10.0).coerceIn(1.0, 60.0)
|
||||
val fpsInt = fps.roundToInt().coerceIn(1, 60)
|
||||
val screenIndex = parseScreenIndex(params)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
val format = parseString(params, key = "format")
|
||||
val screenIndex = parseScreenIndex(paramsJson)
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
val format = parseString(paramsJson, key = "format")
|
||||
if (format != null && format.lowercase() != "mp4") {
|
||||
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
|
||||
}
|
||||
@@ -146,38 +141,55 @@ class ScreenRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
private fun parseDurationMs(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "durationMs")?.toIntOrNull()
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
private fun parseFps(paramsJson: String?): Double? =
|
||||
parseNumber(paramsJson, key = "fps")?.toDoubleOrNull()
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
private fun parseScreenIndex(paramsJson: String?): Int? =
|
||||
parseNumber(paramsJson, key = "screenIndex")?.toIntOrNull()
|
||||
|
||||
private fun parseFps(params: JsonObject?): Double? =
|
||||
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseScreenIndex(params: JsonObject?): Int? =
|
||||
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
private fun parseIncludeAudio(paramsJson: String?): Boolean? {
|
||||
val raw = paramsJson ?: return null
|
||||
val key = "\"includeAudio\""
|
||||
val idx = raw.indexOf(key)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + key.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return when {
|
||||
tail.startsWith("true") -> true
|
||||
tail.startsWith("false") -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseString(params: JsonObject?, key: String): String? =
|
||||
readPrimitive(params, key)?.contentOrNull
|
||||
private fun parseNumber(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
return tail.takeWhile { it.isDigit() || it == '.' || it == '-' }
|
||||
}
|
||||
|
||||
private fun parseString(paramsJson: String?, key: String): String? {
|
||||
val raw = paramsJson ?: return null
|
||||
val needle = "\"$key\""
|
||||
val idx = raw.indexOf(needle)
|
||||
if (idx < 0) return null
|
||||
val colon = raw.indexOf(':', idx + needle.length)
|
||||
if (colon < 0) return null
|
||||
val tail = raw.substring(colon + 1).trimStart()
|
||||
if (!tail.startsWith('\"')) return null
|
||||
val rest = tail.drop(1)
|
||||
val end = rest.indexOf('\"')
|
||||
if (end < 0) return null
|
||||
return rest.substring(0, end)
|
||||
}
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
|
||||
@@ -7,7 +7,6 @@ enum class OpenClawCapability(val rawValue: String) {
|
||||
Sms("sms"),
|
||||
VoiceWake("voiceWake"),
|
||||
Location("location"),
|
||||
Device("device"),
|
||||
}
|
||||
|
||||
enum class OpenClawCanvasCommand(val rawValue: String) {
|
||||
@@ -35,7 +34,6 @@ enum class OpenClawCanvasA2UICommand(val rawValue: String) {
|
||||
}
|
||||
|
||||
enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
List("camera.list"),
|
||||
Snap("camera.snap"),
|
||||
Clip("camera.clip"),
|
||||
;
|
||||
@@ -72,21 +70,8 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawDeviceCommand(val rawValue: String) {
|
||||
Status("device.status"),
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val NamespacePrefix: String = "device."
|
||||
}
|
||||
}
|
||||
|
||||
enum class OpenClawNotificationsCommand(val rawValue: String) {
|
||||
List("notifications.list"),
|
||||
Actions("notifications.actions"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -439,128 +439,4 @@ class GatewaySessionInvokeTest {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = 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":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
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 = { 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 refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
|
||||
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewaySessionInvokeTimeoutTest {
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_usesFloorWhenMissingOrTooSmall() {
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(null))
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(0L))
|
||||
assertEquals(15_000L, resolveInvokeResultAckTimeoutMs(5_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_usesInvokeBudgetWithinBounds() {
|
||||
assertEquals(30_000L, resolveInvokeResultAckTimeoutMs(30_000L))
|
||||
assertEquals(90_000L, resolveInvokeResultAckTimeoutMs(90_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveInvokeResultAckTimeoutMs_capsAtUpperBound() {
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(121_000L))
|
||||
assertEquals(120_000L, resolveInvokeResultAckTimeoutMs(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceCanvasCapabilityInScopedHostUrl_rewritesTerminalCapabilitySegment() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/new-token",
|
||||
replaceCanvasCapabilityInScopedHostUrl(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/old-token",
|
||||
"new-token",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceCanvasCapabilityInScopedHostUrl_rewritesWhenQueryAndFragmentPresent() {
|
||||
assertEquals(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/new-token?a=1#frag",
|
||||
replaceCanvasCapabilityInScopedHostUrl(
|
||||
"http://127.0.0.1:18789/__openclaw__/cap/old-token?a=1#frag",
|
||||
"new-token",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CameraHandlerTest {
|
||||
@Test
|
||||
fun isCameraClipWithinPayloadLimit_allowsZeroAndLimit() {
|
||||
assertTrue(isCameraClipWithinPayloadLimit(0L))
|
||||
assertTrue(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isCameraClipWithinPayloadLimit_rejectsNegativeAndTooLarge() {
|
||||
assertFalse(isCameraClipWithinPayloadLimit(-1L))
|
||||
assertFalse(isCameraClipWithinPayloadLimit(CAMERA_CLIP_MAX_RAW_BYTES + 1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cameraClipMaxRawBytes_matchesExpectedBudget() {
|
||||
assertEquals(18L * 1024L * 1024L, CAMERA_CLIP_MAX_RAW_BYTES)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDevicePermissions_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDevicePermissions(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val permissions = payload.getValue("permissions").jsonObject
|
||||
val expected =
|
||||
listOf(
|
||||
"camera",
|
||||
"microphone",
|
||||
"location",
|
||||
"backgroundLocation",
|
||||
"sms",
|
||||
"notificationListener",
|
||||
"screenCapture",
|
||||
)
|
||||
for (key in expected) {
|
||||
val state = permissions.getValue(key).jsonObject
|
||||
val status = state.getValue("status").jsonPrimitive.content
|
||||
assertTrue(status == "granted" || status == "denied")
|
||||
state.getValue("promptable").jsonPrimitive.boolean
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceHealth_returnsExpectedShape() {
|
||||
val handler = DeviceHandler(appContext())
|
||||
|
||||
val result = handler.handleDeviceHealth(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
val memory = payload.getValue("memory").jsonObject
|
||||
val battery = payload.getValue("battery").jsonObject
|
||||
val power = payload.getValue("power").jsonObject
|
||||
val system = payload.getValue("system").jsonObject
|
||||
|
||||
val pressure = memory.getValue("pressure").jsonPrimitive.content
|
||||
assertTrue(pressure in setOf("normal", "moderate", "high", "critical", "unknown"))
|
||||
val totalRamBytes = memory.getValue("totalRamBytes").jsonPrimitive.content.toLong()
|
||||
val availableRamBytes = memory.getValue("availableRamBytes").jsonPrimitive.content.toLong()
|
||||
val usedRamBytes = memory.getValue("usedRamBytes").jsonPrimitive.content.toLong()
|
||||
assertTrue(totalRamBytes >= 0L)
|
||||
assertTrue(availableRamBytes >= 0L)
|
||||
assertTrue(usedRamBytes >= 0L)
|
||||
memory.getValue("lowMemory").jsonPrimitive.boolean
|
||||
|
||||
val batteryState = battery.getValue("state").jsonPrimitive.content
|
||||
assertTrue(batteryState in setOf("unknown", "unplugged", "charging", "full"))
|
||||
val chargingType = battery.getValue("chargingType").jsonPrimitive.content
|
||||
assertTrue(chargingType in setOf("none", "ac", "usb", "wireless", "dock"))
|
||||
battery["temperatureC"]?.jsonPrimitive?.double
|
||||
battery["currentMa"]?.jsonPrimitive?.double
|
||||
|
||||
power.getValue("dozeModeEnabled").jsonPrimitive.boolean
|
||||
power.getValue("lowPowerModeEnabled").jsonPrimitive.boolean
|
||||
system["securityPatchLevel"]?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
val jsonString = payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(jsonString).jsonObject
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -22,14 +21,8 @@ class InvokeCommandRegistryTest {
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
@@ -48,14 +41,8 @@ class InvokeCommandRegistryTest {
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
|
||||
@@ -95,98 +95,6 @@ class NotificationsHandlerTest {
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_executesDismissAction() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n2","action":"dismiss"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("ok").jsonPrimitive.boolean)
|
||||
assertEquals("n2", payload.getValue("key").jsonPrimitive.content)
|
||||
assertEquals("dismiss", payload.getValue("action").jsonPrimitive.content)
|
||||
assertEquals("n2", provider.lastAction?.key)
|
||||
assertEquals(NotificationActionKind.Dismiss, provider.lastAction?.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_requiresReplyTextForReplyAction() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n3")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n3","action":"reply"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("INVALID_REQUEST", result.error?.code)
|
||||
assertEquals(0, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_propagatesProviderError() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n4")),
|
||||
),
|
||||
).also {
|
||||
it.actionResult =
|
||||
NotificationActionResult(
|
||||
ok = false,
|
||||
code = "NOTIFICATION_NOT_FOUND",
|
||||
message = "NOTIFICATION_NOT_FOUND: notification key not found",
|
||||
)
|
||||
}
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n4","action":"open"}""")
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("NOTIFICATION_NOT_FOUND", result.error?.code)
|
||||
assertEquals(1, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActions_requestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n5")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsActions("""{"key":"n5","action":"open"}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
assertEquals(1, provider.actionRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextReturnsNullForBlankInput() {
|
||||
assertNull(sanitizeNotificationText(null))
|
||||
@@ -202,13 +110,6 @@ class NotificationsHandlerTest {
|
||||
assertTrue((sanitized ?: "").all { it == 'x' })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsActionClearablePolicy_onlyRequiresClearableForDismiss() {
|
||||
assertTrue(actionRequiresClearableNotification(NotificationActionKind.Dismiss))
|
||||
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Open))
|
||||
assertFalse(actionRequiresClearableNotification(NotificationActionKind.Reply))
|
||||
}
|
||||
|
||||
private fun parsePayload(result: GatewaySession.InvokeResult): JsonObject {
|
||||
val payloadJson = result.payloadJson ?: error("expected payload")
|
||||
return Json.parseToJsonElement(payloadJson).jsonObject
|
||||
@@ -236,23 +137,10 @@ private class FakeNotificationsStateProvider(
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
var actionRequests: Int = 0
|
||||
private set
|
||||
var actionResult: NotificationActionResult = NotificationActionResult(ok = true)
|
||||
var lastAction: NotificationActionRequest? = null
|
||||
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot = snapshot
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
rebindRequests += 1
|
||||
}
|
||||
|
||||
override fun executeAction(
|
||||
context: Context,
|
||||
request: NotificationActionRequest,
|
||||
): NotificationActionResult {
|
||||
actionRequests += 1
|
||||
lastAction = request
|
||||
return actionResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,16 +26,6 @@ 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 cameraCommandsUseStableStrings() {
|
||||
assertEquals("camera.list", OpenClawCameraCommand.List.rawValue)
|
||||
assertEquals("camera.snap", OpenClawCameraCommand.Snap.rawValue)
|
||||
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -46,14 +36,5 @@ class OpenClawProtocolConstantsTest {
|
||||
@Test
|
||||
fun notificationsCommandsUseStableStrings() {
|
||||
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)
|
||||
assertEquals("notifications.actions", OpenClawNotificationsCommand.Actions.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceCommandsUseStableStrings() {
|
||||
assertEquals("device.status", OpenClawDeviceCommand.Status.rawValue)
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
|
||||
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -54,12 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let startLogMessage =
|
||||
"chat.send start sessionKey=\(sessionKey) "
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)"
|
||||
)
|
||||
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
|
||||
@@ -212,7 +212,7 @@ final class GatewayConnectionController {
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ final class GatewayConnectionController {
|
||||
self.didAutoConnect = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.connectDiscoveredGateway(target)
|
||||
await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -411,7 +411,7 @@ final class GatewayConnectionController {
|
||||
self.didAutoConnect = true
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -632,8 +632,7 @@ final class GatewayConnectionController {
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
return String(bytes: bytes, encoding: .utf8)
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
@@ -890,9 +889,11 @@ final class GatewayConnectionController {
|
||||
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
|
||||
|
||||
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
permissions["calendar"] = Self.hasEventKitAccess(calendarStatus)
|
||||
permissions["calendar"] =
|
||||
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
|
||||
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
permissions["reminders"] = Self.hasEventKitAccess(remindersStatus)
|
||||
permissions["reminders"] =
|
||||
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
|
||||
|
||||
let motionStatus = CMMotionActivityManager.authorizationStatus()
|
||||
let pedometerStatus = CMPedometer.authorizationStatus()
|
||||
@@ -910,17 +911,13 @@ final class GatewayConnectionController {
|
||||
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasEventKitAccess(_ status: EKAuthorizationStatus) -> Bool {
|
||||
status == .fullAccess || status == .writeOnly
|
||||
}
|
||||
|
||||
private static func motionAvailable() -> Bool {
|
||||
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
|
||||
}
|
||||
@@ -989,7 +986,7 @@ extension GatewayConnectionController {
|
||||
}
|
||||
#endif
|
||||
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate {
|
||||
private let url: URL
|
||||
private let timeoutSeconds: Double
|
||||
private let onComplete: (String?) -> Void
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260226</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -46,7 +46,6 @@ private enum IOSDeepLinkAgentPolicy {
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
// swiftlint:disable type_body_length file_length
|
||||
final class NodeAppModel {
|
||||
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
@@ -415,10 +414,8 @@ final class NodeAppModel {
|
||||
}
|
||||
let wasSuppressed = self.backgroundReconnectSuppressed
|
||||
self.backgroundReconnectSuppressed = false
|
||||
let leaseLogMessage =
|
||||
"Background reconnect lease reason=\(reason) "
|
||||
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
||||
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
||||
self.pushWakeLogger.info(
|
||||
"Background reconnect lease reason=\(reason, privacy: .public) seconds=\(leaseSeconds, privacy: .public) wasSuppressed=\(wasSuppressed, privacy: .public)")
|
||||
}
|
||||
|
||||
private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) {
|
||||
@@ -428,10 +425,8 @@ final class NodeAppModel {
|
||||
self.backgroundReconnectLeaseUntil = nil
|
||||
self.backgroundReconnectSuppressed = true
|
||||
guard changed else { return }
|
||||
let suppressLogMessage =
|
||||
"Background reconnect suppressed reason=\(reason) "
|
||||
+ "disconnect=\(disconnectIfNeeded)"
|
||||
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
||||
self.pushWakeLogger.info(
|
||||
"Background reconnect suppressed reason=\(reason, privacy: .public) disconnect=\(disconnectIfNeeded, privacy: .public)")
|
||||
guard disconnectIfNeeded else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -612,7 +607,7 @@ final class NodeAppModel {
|
||||
self.voiceWakeSyncTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if !self.isGatewayHealthMonitorDisabled() {
|
||||
if !(await self.isGatewayHealthMonitorDisabled()) {
|
||||
await self.refreshWakeWordsFromGateway()
|
||||
}
|
||||
|
||||
@@ -667,13 +662,9 @@ final class NodeAppModel {
|
||||
self.gatewayHealthMonitor.start(
|
||||
check: { [weak self] in
|
||||
guard let self else { return false }
|
||||
if await MainActor.run(body: { self.isGatewayHealthMonitorDisabled() }) { return true }
|
||||
if await self.isGatewayHealthMonitorDisabled() { return true }
|
||||
do {
|
||||
let data = try await self.operatorGateway.request(
|
||||
method: "health",
|
||||
paramsJSON: nil,
|
||||
timeoutSeconds: 6
|
||||
)
|
||||
let data = try await self.operatorGateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6)
|
||||
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
@@ -1774,10 +1765,7 @@ private extension NodeAppModel {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
continue
|
||||
}
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue }
|
||||
if await self.isOperatorConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@@ -1842,8 +1830,6 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
|
||||
// swiftlint:disable:next function_body_length
|
||||
func startNodeGatewayLoop(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
@@ -1868,10 +1854,7 @@ private extension NodeAppModel {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "node_loop") {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
continue
|
||||
}
|
||||
if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue }
|
||||
if await self.isGatewayConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@@ -1915,10 +1898,7 @@ private extension NodeAppModel {
|
||||
sessionKey: relayData.sessionKey,
|
||||
deliveryChannel: relayData.deliveryChannel,
|
||||
deliveryTo: relayData.deliveryTo))
|
||||
GatewayDiagnostics.log(
|
||||
"gateway connected host=\(url.host ?? "?") "
|
||||
+ "scheme=\(url.scheme ?? "?")"
|
||||
)
|
||||
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
}
|
||||
@@ -2013,11 +1993,9 @@ private extension NodeAppModel {
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). "
|
||||
+ "Approve on gateway and return to OpenClaw."
|
||||
"Pairing required (requestId: \(requestId)). Approve on gateway and return to OpenClaw."
|
||||
} else {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required. Approve on gateway and return to OpenClaw."
|
||||
self.gatewayStatusText = "Pairing required. Approve on gateway and return to OpenClaw."
|
||||
}
|
||||
}
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
@@ -2235,16 +2213,12 @@ extension NodeAppModel {
|
||||
key: event.replyId)
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
let forwardedMessage =
|
||||
"watch reply forwarded replyId=\(event.replyId) "
|
||||
+ "action=\(event.actionId)"
|
||||
self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
let failedMessage =
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.watchReplyLogger.error(
|
||||
"watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.queuedWatchReplies.insert(event, at: 0)
|
||||
}
|
||||
}
|
||||
@@ -2278,37 +2252,21 @@ extension NodeAppModel {
|
||||
return false
|
||||
}
|
||||
let pushKind = Self.openclawPushKind(userInfo)
|
||||
let receivedMessage =
|
||||
"Silent push received wakeId=\(wakeId) "
|
||||
+ "kind=\(pushKind) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
self.pushWakeLogger.info(
|
||||
"Silent push received wakeId=\(wakeId, privacy: .public) kind=\(pushKind, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
self.pushWakeLogger.info(
|
||||
"Silent push outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
|
||||
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
|
||||
let wakeId = Self.makePushWakeAttemptID()
|
||||
let receivedMessage =
|
||||
"Background refresh wake received wakeId=\(wakeId) "
|
||||
+ "trigger=\(trigger) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
self.pushWakeLogger.info(
|
||||
"Background refresh wake received wakeId=\(wakeId, privacy: .public) trigger=\(trigger, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Background refresh wake outcome wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||
self.pushWakeLogger.info(
|
||||
"Background refresh wake outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)")
|
||||
return result.applied
|
||||
}
|
||||
|
||||
@@ -2325,26 +2283,17 @@ extension NodeAppModel {
|
||||
if let last = self.lastSignificantLocationWakeAt,
|
||||
now.timeIntervalSince(last) < throttleWindowSeconds
|
||||
{
|
||||
let throttledMessage =
|
||||
"Location wake throttled wakeId=\(wakeId) "
|
||||
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
||||
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
||||
self.locationWakeLogger.info(
|
||||
"Location wake throttled wakeId=\(wakeId, privacy: .public) elapsedSec=\(now.timeIntervalSince(last), privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.lastSignificantLocationWakeAt = now
|
||||
|
||||
let beginMessage =
|
||||
"Location wake begin wakeId=\(wakeId) "
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
||||
self.locationWakeLogger.info(
|
||||
"Location wake begin wakeId=\(wakeId, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)")
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let triggerMessage =
|
||||
"Location wake trigger wakeId=\(wakeId) "
|
||||
+ "applied=\(result.applied) "
|
||||
+ "reason=\(result.reason) "
|
||||
+ "durationMs=\(result.durationMs)"
|
||||
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
||||
self.locationWakeLogger.info(
|
||||
"Location wake trigger wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)")
|
||||
|
||||
guard result.applied else { return }
|
||||
let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250)
|
||||
@@ -2502,18 +2451,14 @@ extension NodeAppModel {
|
||||
extension NodeAppModel {
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.operatorGateway.request(
|
||||
method: "voicewake.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("unauthorized role") || lower.contains("missing scope") {
|
||||
self.setGatewayHealthMonitorDisabled(true)
|
||||
await self.setGatewayHealthMonitorDisabled(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2568,8 +2513,7 @@ extension NodeAppModel {
|
||||
)
|
||||
|
||||
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
||||
self.screen.errorText = "Deep link too large (message exceeds "
|
||||
+ "\(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
|
||||
self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
|
||||
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
|
||||
return
|
||||
}
|
||||
@@ -2784,4 +2728,3 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -20,7 +20,7 @@ final class MotionService: MotionServicing {
|
||||
let limit = max(1, min(params.limit ?? 200, 1000))
|
||||
|
||||
let manager = CMMotionActivityManager()
|
||||
let mapped: [OpenClawMotionActivityEntry] = try await withCheckedThrowingContinuation { cont in
|
||||
let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in
|
||||
manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
@@ -62,7 +62,7 @@ final class MotionService: MotionServicing {
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let pedometer = CMPedometer()
|
||||
let payload: OpenClawPedometerPayload = try await withCheckedThrowingContinuation { cont in
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<OpenClawPedometerPayload, Error>) in
|
||||
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
|
||||
@@ -134,10 +134,7 @@ struct OnboardingWizardView: View {
|
||||
Button("Done") {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,10 +716,8 @@ struct OnboardingWizardView: View {
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
ofType: CIDetectorTypeQRCode,
|
||||
context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
|
||||
)
|
||||
ofType: CIDetectorTypeQRCode, context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||
let features = detector?.features(in: ciImage) ?? []
|
||||
for feature in features {
|
||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||
|
||||
@@ -4,7 +4,7 @@ import OpenClawKit
|
||||
import os
|
||||
import UIKit
|
||||
import BackgroundTasks
|
||||
@preconcurrency import UserNotifications
|
||||
import UserNotifications
|
||||
|
||||
private struct PendingWatchPromptAction {
|
||||
var promptId: String?
|
||||
@@ -119,19 +119,11 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
request.earliestBeginDate = Date().addingTimeInterval(max(60, delay))
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
let scheduledLogMessage =
|
||||
"Scheduled background wake refresh reason=\(reason) "
|
||||
+ "delaySeconds=\(max(60, delay))"
|
||||
self.backgroundWakeLogger.info(
|
||||
"\(scheduledLogMessage, privacy: .public)"
|
||||
)
|
||||
"Scheduled background wake refresh reason=\(reason, privacy: .public) delaySeconds=\(max(60, delay), privacy: .public)")
|
||||
} catch {
|
||||
let failedLogMessage =
|
||||
"Failed scheduling background wake refresh reason=\(reason) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.backgroundWakeLogger.error(
|
||||
"\(failedLogMessage, privacy: .public)"
|
||||
)
|
||||
"Failed scheduling background wake refresh reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,9 +418,7 @@ enum WatchPromptNotificationBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func notificationAuthorizationStatus(
|
||||
center: UNUserNotificationCenter
|
||||
) async -> UNAuthorizationStatus {
|
||||
private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus {
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
@@ -450,10 +440,7 @@ enum WatchPromptNotificationBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func addNotificationRequest(
|
||||
_ request: UNNotificationRequest,
|
||||
center: UNUserNotificationCenter
|
||||
) async throws {
|
||||
private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
if let error {
|
||||
|
||||
@@ -17,7 +17,7 @@ final class RemindersService: RemindersServicing {
|
||||
let statusFilter = params.status ?? .incomplete
|
||||
|
||||
let predicate = store.predicateForReminders(in: nil)
|
||||
let payload: [OpenClawReminderPayload] = try await withCheckedThrowingContinuation { cont in
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in
|
||||
store.fetchReminders(matching: predicate) { items in
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let filtered = (items ?? []).filter { reminder in
|
||||
|
||||
@@ -3,13 +3,10 @@ import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
typealias OpenClawCameraSnapResult = (format: String, base64: String, width: Int, height: Int)
|
||||
typealias OpenClawCameraClipResult = (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
|
||||
protocol CameraServicing: Sendable {
|
||||
func listDevices() async -> [CameraController.CameraDeviceInfo]
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> OpenClawCameraSnapResult
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> OpenClawCameraClipResult
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int)
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
}
|
||||
|
||||
protocol ScreenRecordingServicing: Sendable {
|
||||
|
||||
@@ -148,15 +148,11 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
|
||||
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
session.sendMessage(
|
||||
payload,
|
||||
replyHandler: { _ in
|
||||
continuation.resume()
|
||||
},
|
||||
errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
)
|
||||
session.sendMessage(payload, replyHandler: { _ in
|
||||
continuation.resume()
|
||||
}, errorHandler: { error in
|
||||
continuation.resume(throwing: error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// swiftlint:disable type_body_length
|
||||
struct SettingsTab: View {
|
||||
private struct FeatureHelp: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -23,6 +22,7 @@ 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
|
||||
@@ -229,10 +229,7 @@ struct SettingsTab: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -279,9 +276,7 @@ struct SettingsTab: View {
|
||||
self.featureToggle(
|
||||
"Allow Camera",
|
||||
isOn: self.$cameraEnabled,
|
||||
help: "Allows the gateway to request photos or short video clips "
|
||||
+ "while OpenClaw is foregrounded."
|
||||
)
|
||||
help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Location Access")
|
||||
@@ -289,11 +284,7 @@ struct SettingsTab: View {
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Location Access",
|
||||
message: "Controls location permissions for OpenClaw. "
|
||||
+ "Off disables location tools, While Using enables "
|
||||
+ "foreground location, and Always enables "
|
||||
+ "background location."
|
||||
)
|
||||
message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -323,11 +314,7 @@ struct SettingsTab: View {
|
||||
LabeledContent(
|
||||
"API Key",
|
||||
value: self.appModel.talkMode.gatewayTalkConfigLoaded
|
||||
? (
|
||||
self.appModel.talkMode.gatewayTalkApiKeyConfigured
|
||||
? "Configured"
|
||||
: "Not configured"
|
||||
)
|
||||
? (self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured")
|
||||
: "Not loaded")
|
||||
LabeledContent(
|
||||
"Default Model",
|
||||
@@ -339,6 +326,10 @@ 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,
|
||||
@@ -354,9 +345,7 @@ struct SettingsTab: View {
|
||||
Button {
|
||||
self.activeFeatureHelp = FeatureHelp(
|
||||
title: "Default Share Instruction",
|
||||
message: "Appends this instruction when sharing content "
|
||||
+ "into OpenClaw from iOS."
|
||||
)
|
||||
message: "Appends this instruction when sharing content into OpenClaw from iOS.")
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -409,9 +398,7 @@ struct SettingsTab: View {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"This will disconnect, clear saved gateway connection + credentials, "
|
||||
+ "and reopen the onboarding wizard."
|
||||
)
|
||||
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
|
||||
}
|
||||
.alert(item: self.$activeFeatureHelp) { help in
|
||||
Alert(
|
||||
@@ -719,9 +706,7 @@ struct SettingsTab: View {
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
GatewayDiagnostics.log(
|
||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) "
|
||||
+ "tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)"
|
||||
)
|
||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
||||
guard let port = resolvedPort else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
@@ -1029,4 +1014,3 @@ struct SettingsTab: View {
|
||||
return lines
|
||||
}
|
||||
}
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -51,11 +51,7 @@ struct StatusPill: View {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0
|
||||
)
|
||||
.scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
|
||||
@@ -10,7 +10,7 @@ import Speech
|
||||
// This file intentionally centralizes talk mode state + behavior.
|
||||
// It's large, and splitting would force `private` -> `fileprivate` across many members.
|
||||
// We'll refactor into smaller files when the surface stabilizes.
|
||||
// swiftlint:disable type_body_length file_length
|
||||
// swiftlint:disable type_body_length
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TalkModeManager: NSObject {
|
||||
@@ -156,7 +156,9 @@ final class TalkModeManager: NSObject {
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.logger.warning("start blocked: microphone permission denied")
|
||||
self.statusText = "Microphone permission denied"
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Microphone",
|
||||
status: AVAudioSession.sharedInstance().recordPermission)
|
||||
return
|
||||
}
|
||||
let speechOk = await Self.requestSpeechPermission()
|
||||
@@ -298,7 +300,9 @@ final class TalkModeManager: NSObject {
|
||||
if !self.allowSimulatorCapture {
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.statusText = "Microphone permission denied"
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Microphone",
|
||||
status: AVAudioSession.sharedInstance().recordPermission)
|
||||
throw NSError(domain: "TalkMode", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Microphone permission denied",
|
||||
])
|
||||
@@ -466,15 +470,14 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
if self.allowSimulatorCapture {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
if !self.allowSimulatorCapture {
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
} else {
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -522,9 +525,7 @@ final class TalkModeManager: NSObject {
|
||||
self.noiseFloorSamples.removeAll(keepingCapacity: true)
|
||||
let threshold = min(0.35, max(0.12, avg + 0.10))
|
||||
GatewayDiagnostics.log(
|
||||
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) "
|
||||
+ "threshold=\(String(format: "%.3f", threshold))"
|
||||
)
|
||||
"talk audio: noiseFloor=\(String(format: "%.3f", avg)) threshold=\(String(format: "%.3f", threshold))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,9 +549,7 @@ final class TalkModeManager: NSObject {
|
||||
self.loggedPartialThisCycle = false
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"talk speech: recognition started mode=\(String(describing: self.captureMode)) "
|
||||
+ "engineRunning=\(self.audioEngine.isRunning)"
|
||||
)
|
||||
"talk speech: recognition started mode=\(String(describing: self.captureMode)) engineRunning=\(self.audioEngine.isRunning)")
|
||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
@@ -851,10 +850,11 @@ 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: false)
|
||||
includeVoiceDirectiveHint: includeVoiceDirectiveHint)
|
||||
}
|
||||
|
||||
private enum ChatCompletionState: CustomStringConvertible {
|
||||
@@ -1317,11 +1317,11 @@ final class TalkModeManager: NSObject {
|
||||
try Task.checkCancellation()
|
||||
chunks.append(chunk)
|
||||
}
|
||||
self?.completeIncrementalPrefetch(id: id, chunks: chunks)
|
||||
await self?.completeIncrementalPrefetch(id: id, chunks: chunks)
|
||||
} catch is CancellationError {
|
||||
self?.clearIncrementalPrefetch(id: id)
|
||||
await self?.clearIncrementalPrefetch(id: id)
|
||||
} catch {
|
||||
self?.failIncrementalPrefetch(id: id, error: error)
|
||||
await self?.failIncrementalPrefetch(id: id, error: error)
|
||||
}
|
||||
}
|
||||
self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState(
|
||||
@@ -1427,10 +1427,7 @@ final class TalkModeManager: NSObject {
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard evt.event == "agent", let payload = evt.payload else { continue }
|
||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(
|
||||
payload,
|
||||
as: OpenClawAgentEventPayload.self
|
||||
) else {
|
||||
guard let agentEvent = try? GatewayPayloadDecoding.decode(payload, as: OpenClawAgentEventPayload.self) else {
|
||||
continue
|
||||
}
|
||||
guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue }
|
||||
@@ -1730,20 +1727,23 @@ private struct IncrementalSpeechBuffer {
|
||||
|
||||
extension TalkModeManager {
|
||||
nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch session.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioApplication.requestRecordPermission(completionHandler: { ok in
|
||||
completion(ok)
|
||||
})
|
||||
}
|
||||
break
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { ok in
|
||||
completion(ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
@@ -1767,7 +1767,7 @@ extension TalkModeManager {
|
||||
}
|
||||
|
||||
private nonisolated static func requestPermissionWithTimeout(
|
||||
_ operation: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool
|
||||
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
@@ -1911,7 +1911,7 @@ extension TalkModeManager {
|
||||
}
|
||||
let providerID =
|
||||
Self.normalizedTalkProviderID(rawProvider) ??
|
||||
normalizedProviders.keys.min() ??
|
||||
normalizedProviders.keys.sorted().first ??
|
||||
Self.defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
@@ -1921,11 +1921,7 @@ extension TalkModeManager {
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
let res = try await gateway.request(
|
||||
method: "talk.config",
|
||||
paramsJSON: "{\"includeSecrets\":true}",
|
||||
timeoutSeconds: 8
|
||||
)
|
||||
let res = try await gateway.request(method: "talk.config", paramsJSON: "{\"includeSecrets\":true}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
@@ -2012,18 +2008,10 @@ extension TalkModeManager {
|
||||
|
||||
private static func describeAudioSession() -> String {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let inputs = session.currentRoute.inputs
|
||||
.map { "\($0.portType.rawValue):\($0.portName)" }
|
||||
.joined(separator: ",")
|
||||
let outputs = session.currentRoute.outputs
|
||||
.map { "\($0.portType.rawValue):\($0.portName)" }
|
||||
.joined(separator: ",")
|
||||
let available = session.availableInputs?
|
||||
.map { "\($0.portType.rawValue):\($0.portName)" }
|
||||
.joined(separator: ",") ?? ""
|
||||
return "category=\(session.category.rawValue) mode=\(session.mode.rawValue) "
|
||||
+ "opts=\(session.categoryOptions.rawValue) inputAvail=\(session.isInputAvailable) "
|
||||
+ "routeIn=[\(inputs)] routeOut=[\(outputs)] availIn=[\(available)]"
|
||||
let inputs = session.currentRoute.inputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
|
||||
let outputs = session.currentRoute.outputs.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",")
|
||||
let available = session.availableInputs?.map { "\($0.portType.rawValue):\($0.portName)" }.joined(separator: ",") ?? ""
|
||||
return "category=\(session.category.rawValue) mode=\(session.mode.rawValue) opts=\(session.categoryOptions.rawValue) inputAvail=\(session.isInputAvailable) routeIn=[\(inputs)] routeOut=[\(outputs)] availIn=[\(available)]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2091,9 +2079,7 @@ private final class AudioTapDiagnostics: @unchecked Sendable {
|
||||
|
||||
guard shouldLog else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) "
|
||||
+ "rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))"
|
||||
)
|
||||
"\(label) mic: buffers=\(count) frames=\(frames) rate=\(Int(rate))Hz ch=\(ch) rms=\(String(format: "%.4f", resolvedRms)) max=\(String(format: "%.4f", maxRms))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2150,4 +2136,4 @@ private struct IncrementalPrefetchedAudio {
|
||||
let outputFormat: String?
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length file_length
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260226</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</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.27</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260226</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -92,8 +92,8 @@ targets:
|
||||
- CFBundleURLName: ai.openclaw.ios
|
||||
CFBundleURLSchemes:
|
||||
- openclaw
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -133,13 +133,11 @@ targets:
|
||||
- path: ShareExtension
|
||||
dependencies:
|
||||
- package: OpenClawKit
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
|
||||
SWIFT_VERSION: "6.0"
|
||||
@@ -148,8 +146,8 @@ targets:
|
||||
path: ShareExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw Share
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.share-services
|
||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||
@@ -173,14 +171,13 @@ targets:
|
||||
Release: Config/Signing.xcconfig
|
||||
settings:
|
||||
base:
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
info:
|
||||
path: WatchApp/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
WKWatchKitApp: true
|
||||
|
||||
@@ -203,8 +200,8 @@ targets:
|
||||
path: WatchExtension/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
NSExtension:
|
||||
NSExtensionAttributes:
|
||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||
@@ -237,5 +234,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.27"
|
||||
CFBundleVersion: "20260227"
|
||||
CFBundleShortVersionString: "2026.2.26"
|
||||
CFBundleVersion: "20260226"
|
||||
|
||||
@@ -355,9 +355,9 @@ private enum ExecHostExecutor {
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let validatedRequest: ExecHostValidatedRequest
|
||||
switch ExecHostRequestEvaluator.validateRequest(request) {
|
||||
case let .success(request):
|
||||
case .success(let request):
|
||||
validatedRequest = request
|
||||
case let .failure(error):
|
||||
case .failure(let error):
|
||||
return self.errorResponse(error)
|
||||
}
|
||||
|
||||
@@ -370,7 +370,7 @@ private enum ExecHostExecutor {
|
||||
context: context,
|
||||
approvalDecision: request.approvalDecision)
|
||||
{
|
||||
case let .deny(error):
|
||||
case .deny(let error):
|
||||
return self.errorResponse(error)
|
||||
case .allow:
|
||||
break
|
||||
@@ -401,7 +401,7 @@ private enum ExecHostExecutor {
|
||||
context: context,
|
||||
approvalDecision: followupDecision)
|
||||
{
|
||||
case let .deny(error):
|
||||
case .deny(let error):
|
||||
return self.errorResponse(error)
|
||||
case .allow:
|
||||
break
|
||||
|
||||
@@ -26,9 +26,9 @@ enum ExecHostRequestEvaluator {
|
||||
command: command,
|
||||
rawCommand: request.rawCommand)
|
||||
switch validatedCommand {
|
||||
case let .ok(resolved):
|
||||
case .ok(let resolved):
|
||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||
case let .invalid(message):
|
||||
case .invalid(let message):
|
||||
return .failure(
|
||||
ExecHostError(
|
||||
code: "INVALID_REQUEST",
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
||||
/// Keep in sync with src/infra/host-env-security-policy.json.
|
||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
private static let blockedKeys = HostEnvSecurityPolicy.blockedKeys
|
||||
private static let blockedPrefixes = HostEnvSecurityPolicy.blockedPrefixes
|
||||
private static let blockedOverrideKeys = HostEnvSecurityPolicy.blockedOverrideKeys
|
||||
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 shellWrapperAllowedOverrideKeys: Set<String> = [
|
||||
"TERM",
|
||||
"LANG",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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.27</string>
|
||||
<string>2026.2.26</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602270</string>
|
||||
<string>202602260</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -280,17 +280,19 @@ actor GatewayWizardClient {
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
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")
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
let payloadParts = [
|
||||
"v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
connectNonce,
|
||||
]
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
|
||||
@@ -2810,8 +2810,6 @@ 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?
|
||||
@@ -2820,10 +2818,6 @@ 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?
|
||||
|
||||
@@ -2831,8 +2825,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2841,18 +2833,12 @@ 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
|
||||
@@ -2861,10 +2847,6 @@ 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
|
||||
}
|
||||
@@ -2873,8 +2855,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2883,10 +2863,6 @@ 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"
|
||||
}
|
||||
@@ -3000,7 +2976,6 @@ 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?
|
||||
@@ -3017,7 +2992,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -3033,7 +3007,6 @@ 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
|
||||
@@ -3051,7 +3024,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
|
||||
@@ -105,9 +105,7 @@ enum ChatMarkdownPreprocessor {
|
||||
outputLines.append(currentLine)
|
||||
}
|
||||
|
||||
return outputLines
|
||||
.joined(separator: "\n")
|
||||
.replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression)
|
||||
return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
private static func stripPrefixedTimestamps(_ raw: String) -> String {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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,18 +398,20 @@ 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,8 +2810,6 @@ 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?
|
||||
@@ -2820,10 +2818,6 @@ 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?
|
||||
|
||||
@@ -2831,8 +2825,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2841,18 +2833,12 @@ 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
|
||||
@@ -2861,10 +2847,6 @@ 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
|
||||
}
|
||||
@@ -2873,8 +2855,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2883,10 +2863,6 @@ 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"
|
||||
}
|
||||
@@ -3000,7 +2976,6 @@ 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?
|
||||
@@ -3017,7 +2992,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -3033,7 +3007,6 @@ 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
|
||||
@@ -3051,7 +3024,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
case publickey = "publicKey"
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case devicefamily = "deviceFamily"
|
||||
case clientid = "clientId"
|
||||
case clientmode = "clientMode"
|
||||
case role
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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,9 +13,6 @@ const BADGE = {
|
||||
let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
let relayGatewayToken = ''
|
||||
/** @type {string|null} */
|
||||
let relayConnectRequestId = null
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
@@ -146,13 +143,6 @@ 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)
|
||||
@@ -172,6 +162,10 @@ 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')
|
||||
@@ -194,8 +188,6 @@ 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)
|
||||
@@ -316,33 +308,6 @@ 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'])
|
||||
@@ -384,33 +349,6 @@ 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' })
|
||||
|
||||
@@ -38,7 +38,7 @@ Create a one-shot reminder, verify it exists, and run it immediately:
|
||||
openclaw cron add \
|
||||
--name "Reminder" \
|
||||
--at "2026-02-01T16:00:00Z" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Reminder: check the cron docs draft" \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
@@ -55,7 +55,7 @@ openclaw cron add \
|
||||
--name "Morning brief" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Summarize overnight updates." \
|
||||
--announce \
|
||||
--channel slack \
|
||||
@@ -479,7 +479,7 @@ One-shot reminder (UTC ISO, auto-delete after success):
|
||||
openclaw cron add \
|
||||
--name "Send reminder" \
|
||||
--at "2026-01-12T18:00:00Z" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Reminder: submit expense report." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
@@ -491,7 +491,7 @@ One-shot reminder (main session, wake immediately):
|
||||
openclaw cron add \
|
||||
--name "Calendar check" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Next heartbeat: check calendar." \
|
||||
--wake now
|
||||
```
|
||||
@@ -503,7 +503,7 @@ openclaw cron add \
|
||||
--name "Morning status" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Summarize inbox + calendar for today." \
|
||||
--announce \
|
||||
--channel whatsapp \
|
||||
@@ -518,7 +518,7 @@ openclaw cron add \
|
||||
--cron "0 * * * * *" \
|
||||
--tz "UTC" \
|
||||
--stagger 30s \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Run minute watcher checks." \
|
||||
--announce
|
||||
```
|
||||
@@ -530,7 +530,7 @@ openclaw cron add \
|
||||
--name "Nightly summary (topic)" \
|
||||
--cron "0 22 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Summarize today; send to the nightly topic." \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
@@ -544,7 +544,7 @@ openclaw cron add \
|
||||
--name "Deep analysis" \
|
||||
--cron "0 6 * * 1" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Weekly deep analysis of project progress." \
|
||||
--model "opus" \
|
||||
--thinking high \
|
||||
@@ -557,7 +557,7 @@ Agent selection (multi-agent setups):
|
||||
|
||||
```bash
|
||||
# Pin a job to agent "ops" (falls back to default if that agent is missing)
|
||||
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
|
||||
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session-target isolated --message "Check ops queue" --agent ops
|
||||
|
||||
# Switch or clear the agent on an existing job
|
||||
openclaw cron edit <jobId> --agent ops
|
||||
|
||||
@@ -106,7 +106,7 @@ openclaw cron add \
|
||||
--name "Morning briefing" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/New_York" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||
--model opus \
|
||||
--announce \
|
||||
@@ -122,7 +122,7 @@ This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces
|
||||
openclaw cron add \
|
||||
--name "Meeting reminder" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Reminder: standup meeting starts in 10 minutes." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
@@ -178,13 +178,13 @@ The most efficient setup uses **both**:
|
||||
|
||||
```bash
|
||||
# Daily morning briefing at 7am
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
|
||||
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session-target isolated --message "..." --announce
|
||||
|
||||
# Weekly project review on Mondays at 9am
|
||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session-target isolated --message "..." --model opus
|
||||
|
||||
# One-shot reminder
|
||||
openclaw cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
|
||||
openclaw cron add --name "Call back" --at "2h" --session-target main --system-event "Call back the client" --wake now
|
||||
```
|
||||
|
||||
## Lobster: Deterministic workflows with approvals
|
||||
@@ -229,7 +229,7 @@ Both heartbeat and cron can interact with the main session, but differently:
|
||||
|
||||
### When to use main session cron
|
||||
|
||||
Use `--session main` with `--system-event` when you want:
|
||||
Use `--session-target main` with `--system-event` when you want:
|
||||
|
||||
- The reminder/event to appear in main session context
|
||||
- The agent to handle it during the next heartbeat with full context
|
||||
@@ -239,14 +239,14 @@ Use `--session main` with `--system-event` when you want:
|
||||
openclaw cron add \
|
||||
--name "Check project" \
|
||||
--every "4h" \
|
||||
--session main \
|
||||
--session-target main \
|
||||
--system-event "Time for a project health check" \
|
||||
--wake now
|
||||
```
|
||||
|
||||
### When to use isolated cron
|
||||
|
||||
Use `--session isolated` when you want:
|
||||
Use `--session-target isolated` when you want:
|
||||
|
||||
- A clean slate without prior context
|
||||
- Different model or thinking settings
|
||||
@@ -257,7 +257,7 @@ Use `--session isolated` when you want:
|
||||
openclaw cron add \
|
||||
--name "Deep analysis" \
|
||||
--cron "0 6 * * 0" \
|
||||
--session isolated \
|
||||
--session-target isolated \
|
||||
--message "Weekly codebase analysis..." \
|
||||
--model opus \
|
||||
--thinking high \
|
||||
|
||||
@@ -642,8 +642,7 @@ Default slash command settings:
|
||||
- `/focus <target>` bind current/new thread to a subagent/session target
|
||||
- `/unfocus` remove current thread binding
|
||||
- `/agents` show active runs and binding state
|
||||
- `/session idle <duration|off>` inspect/update inactivity auto-unfocus for focused bindings
|
||||
- `/session max-age <duration|off>` inspect/update hard max age for focused bindings
|
||||
- `/session ttl <duration|off>` inspect/update auto-unfocus TTL for focused bindings
|
||||
|
||||
Config:
|
||||
|
||||
@@ -652,16 +651,14 @@ Default slash command settings:
|
||||
session: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
ttlHours: 24,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
ttlHours: 24,
|
||||
spawnSubagentSessions: false, // opt-in
|
||||
},
|
||||
},
|
||||
@@ -674,10 +671,9 @@ 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), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference).
|
||||
See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -166,7 +166,6 @@ 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",
|
||||
@@ -195,15 +194,12 @@ 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
|
||||
|
||||
31
docs/channels/grammy.md
Normal file
31
docs/channels/grammy.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
summary: "Telegram Bot API integration via grammY with setup notes"
|
||||
read_when:
|
||||
- Working on Telegram or grammY pathways
|
||||
title: grammY
|
||||
---
|
||||
|
||||
# grammY Integration (Telegram Bot API)
|
||||
|
||||
# Why grammY
|
||||
|
||||
- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter.
|
||||
- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods.
|
||||
- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context.
|
||||
|
||||
# What we shipped
|
||||
|
||||
- **Single client path:** fetch-based implementation removed; grammY is now the sole Telegram client (send + gateway) with the grammY throttler enabled by default.
|
||||
- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`.
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||
- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
Open questions
|
||||
|
||||
- Optional grammY plugins (throttler) if we hit Bot API 429s.
|
||||
- Add more structured media tests (stickers, voice notes).
|
||||
- Make webhook listen port configurable (currently fixed to 8787 unless wired through the gateway).
|
||||
@@ -184,7 +184,6 @@ 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,5 +43,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
stores more state on disk.
|
||||
- Group behavior varies by channel; see [Groups](/channels/groups).
|
||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||
- Telegram internals: [grammY notes](/channels/grammy).
|
||||
- Troubleshooting: [Channel troubleshooting](/channels/troubleshooting).
|
||||
- Model providers are documented separately; see [Model Providers](/providers/models).
|
||||
|
||||
@@ -109,15 +109,13 @@ Token resolution order is account-aware. In practice, config values win over env
|
||||
`channels.telegram.dmPolicy` controls direct message access:
|
||||
|
||||
- `pairing` (default)
|
||||
- `allowlist` (requires at least one sender ID in `allowFrom`)
|
||||
- `allowlist`
|
||||
- `open` (requires `allowFrom` to include `"*"`)
|
||||
- `disabled`
|
||||
|
||||
`channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized.
|
||||
`dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation.
|
||||
The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
|
||||
If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
|
||||
If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet).
|
||||
|
||||
### Finding your Telegram user ID
|
||||
|
||||
@@ -138,12 +136,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
</Tab>
|
||||
|
||||
<Tab title="Group policy and allowlists">
|
||||
Two controls apply together:
|
||||
There are two independent controls:
|
||||
|
||||
1. **Which groups are allowed** (`channels.telegram.groups`)
|
||||
- no `groups` config:
|
||||
- with `groupPolicy: "open"`: any group can pass group-ID checks
|
||||
- with `groupPolicy: "allowlist"` (default): groups are blocked until you add `groups` entries (or `"*"`)
|
||||
- no `groups` config: all groups allowed
|
||||
- `groups` configured: acts as allowlist (explicit IDs or `"*"`)
|
||||
|
||||
2. **Which senders are allowed in groups** (`channels.telegram.groupPolicy`)
|
||||
@@ -152,11 +148,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `disabled`
|
||||
|
||||
`groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`.
|
||||
`groupAllowFrom` entries should be numeric Telegram user IDs (`telegram:` / `tg:` prefixes are normalized).
|
||||
Non-numeric entries are ignored for sender authorization.
|
||||
Security boundary (`2026.2.25+`): group sender auth does **not** inherit DM pairing-store approvals.
|
||||
Pairing stays DM-only. For groups, set `groupAllowFrom` or per-group/per-topic `allowFrom`.
|
||||
Runtime note: if `channels.telegram` is completely missing, runtime defaults to fail-closed `groupPolicy="allowlist"` unless `channels.defaults.groupPolicy` is explicitly set.
|
||||
`groupAllowFrom` entries must be numeric Telegram user IDs.
|
||||
Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
Example: allow any member in one specific group:
|
||||
|
||||
@@ -390,19 +383,17 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `react` (`chatId`, `messageId`, `emoji`)
|
||||
- `deleteMessage` (`chatId`, `messageId`)
|
||||
- `editMessage` (`chatId`, `messageId`, `content`)
|
||||
- `createForumTopic` (`chatId`, `name`, optional `iconColor`, `iconCustomEmojiId`)
|
||||
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`, `topic-create`).
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`).
|
||||
|
||||
Gating controls:
|
||||
|
||||
- `channels.telegram.actions.sendMessage`
|
||||
- `channels.telegram.actions.editMessage`
|
||||
- `channels.telegram.actions.deleteMessage`
|
||||
- `channels.telegram.actions.reactions`
|
||||
- `channels.telegram.actions.sticker` (default: disabled)
|
||||
|
||||
Note: `edit` and `topic-create` are currently enabled by default and do not have separate `channels.telegram.actions.*` toggles.
|
||||
|
||||
Reaction removal semantics: [/tools/reactions](/tools/reactions)
|
||||
|
||||
</Accordion>
|
||||
@@ -619,7 +610,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- set `channels.telegram.webhookSecret` (required when webhook URL is set)
|
||||
- optional `channels.telegram.webhookPath` (default `/telegram-webhook`)
|
||||
- optional `channels.telegram.webhookHost` (default `127.0.0.1`)
|
||||
- optional `channels.telegram.webhookPort` (default `8787`)
|
||||
|
||||
Default local listener for webhook mode binds to `127.0.0.1:8787`.
|
||||
|
||||
@@ -637,7 +627,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- DM history controls:
|
||||
- `channels.telegram.dmHistoryLimit`
|
||||
- `channels.telegram.dms["<user_id>"].historyLimit`
|
||||
- `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors.
|
||||
- outbound Telegram API retries are configurable via `channels.telegram.retry`.
|
||||
|
||||
CLI send target can be numeric chat ID or username:
|
||||
|
||||
@@ -726,10 +716,9 @@ Primary reference:
|
||||
- `channels.telegram.botToken`: bot token (BotFather).
|
||||
- `channels.telegram.tokenFile`: read token from file path.
|
||||
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
|
||||
- `channels.telegram.defaultTo`: default Telegram target used by CLI `--deliver` when no explicit `--reply-to` is provided.
|
||||
- `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. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).
|
||||
- `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.
|
||||
@@ -746,14 +735,13 @@ Primary reference:
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.commands.nativeSkills`: enable/disable Telegram native skills commands.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `off`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
|
||||
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+.
|
||||
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||
@@ -761,7 +749,6 @@ Primary reference:
|
||||
- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set).
|
||||
- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
||||
- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`).
|
||||
- `channels.telegram.webhookPort`: local webhook bind port (default `8787`).
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
@@ -775,7 +762,7 @@ Telegram-specific high-signal fields:
|
||||
|
||||
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*`
|
||||
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
|
||||
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
|
||||
- command/menu: `commands.native`, `customCommands`
|
||||
- threading/replies: `replyToMode`
|
||||
- streaming: `streaming` (preview), `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "acp"
|
||||
|
||||
# acp
|
||||
|
||||
Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge that talks to a OpenClaw Gateway.
|
||||
Run the ACP (Agent Client Protocol) bridge that talks to a OpenClaw Gateway.
|
||||
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
|
||||
@@ -52,7 +52,6 @@ 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)
|
||||
@@ -105,9 +104,6 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
dashboard
|
||||
security
|
||||
audit
|
||||
secrets
|
||||
reload
|
||||
migrate
|
||||
reset
|
||||
uninstall
|
||||
update
|
||||
@@ -267,13 +263,6 @@ 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:
|
||||
@@ -328,8 +317,7 @@ Interactive wizard to set up gateway, workspace, and skills.
|
||||
Options:
|
||||
|
||||
- `--workspace <dir>`
|
||||
- `--reset` (reset config + credentials + sessions before wizard)
|
||||
- `--reset-scope <config|config+creds+sessions|full>` (default `config+creds+sessions`; use `full` to also remove workspace)
|
||||
- `--reset` (reset config + credentials + sessions + workspace before wizard)
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
@@ -338,7 +326,6 @@ 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>`
|
||||
|
||||
@@ -34,39 +34,11 @@ 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`).
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
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`
|
||||
|
||||
Use `openclaw secrets` to migrate credentials from plaintext to SecretRefs and keep the active secrets runtime healthy.
|
||||
|
||||
Command roles:
|
||||
|
||||
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
|
||||
- `audit`: read-only scan of config + auth stores + legacy residues (`.env`, `auth.json`) for plaintext, unresolved refs, and precedence drift.
|
||||
- `configure`: interactive planner for provider setup + target mapping + preflight (TTY required).
|
||||
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub migrated plaintext residues.
|
||||
|
||||
Recommended operator loop:
|
||||
|
||||
```bash
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets configure
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets reload
|
||||
```
|
||||
|
||||
Exit code note for CI/gates:
|
||||
|
||||
- `audit --check` returns `1` on findings, `2` when refs are unresolved.
|
||||
|
||||
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 and returns an error (no partial activation).
|
||||
- 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.
|
||||
|
||||
Report shape highlights:
|
||||
|
||||
- `status`: `clean | findings | unresolved`
|
||||
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
|
||||
- finding codes:
|
||||
- `PLAINTEXT_FOUND`
|
||||
- `REF_UNRESOLVED`
|
||||
- `REF_SHADOWED`
|
||||
- `LEGACY_RESIDUE`
|
||||
|
||||
## 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:
|
||||
|
||||
- Requires an interactive TTY.
|
||||
- You cannot combine `--providers-only` with `--skip-provider-setup`.
|
||||
- `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.
|
||||
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
|
||||
- Apply path is one-way for migrated plaintext values.
|
||||
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
|
||||
- With `--apply` (and no `--yes`), CLI prompts an extra irreversible-migration confirmation.
|
||||
|
||||
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)
|
||||
|
||||
What `apply` may update:
|
||||
|
||||
- `openclaw.json` (SecretRef targets + provider upserts/deletes)
|
||||
- `auth-profiles.json` (provider-target scrubbing)
|
||||
- legacy `auth.json` residues
|
||||
- `~/.openclaw/.env` known secret keys whose values were migrated
|
||||
|
||||
## 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.
|
||||
@@ -40,61 +40,6 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with
|
||||
Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report.
|
||||
For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security).
|
||||
|
||||
## Skill security
|
||||
|
||||
Community skills (installed from ClawHub) are subject to additional security enforcement:
|
||||
|
||||
- **SKILL.md scanning**: content is scanned for prompt injection patterns, capability inflation, and boundary spoofing before entering the system prompt. Skills with critical findings are blocked from loading.
|
||||
- **Capability declarations**: community skills should declare `capabilities` (e.g., `shell`, `network`) in frontmatter for visibility and policy checks.
|
||||
- **Current rollout scope**: command-dispatch safety checks and SKILL.md scanning are active in this phase; broader runtime capability gating is rolling out in stages.
|
||||
- **Command dispatch gating**: community skills using `command-dispatch: tool` can't dispatch to dangerous tools without the matching capability.
|
||||
- **Audit logging**: all security events are tagged with `category: "security"` and include session context for forensics. View in the web UI Logs tab using the Security filter.
|
||||
|
||||
See `openclaw skills check` for a runtime security overview, `openclaw skills info <name>` for per-skill details, and [Skills — Tool enforcement matrix](/tools/skills#tool-enforcement-matrix) for the complete tool-by-tool breakdown.
|
||||
|
||||
### Tool enforcement matrix
|
||||
|
||||
Every tool falls into one of three tiers when community skills are loaded:
|
||||
|
||||
**Always denied** — blocked unconditionally, no capability can override:
|
||||
|
||||
| Tool | Reason |
|
||||
| --------- | --------------------------------------------------------------- |
|
||||
| `gateway` | Control-plane reconfiguration (restart, shutdown, auth changes) |
|
||||
| `nodes` | Cluster node management (add/remove compute, redirect traffic) |
|
||||
|
||||
**Capability-gated** — blocked by default, allowed if the skill declares the matching capability:
|
||||
|
||||
| Capability | Tools | What it unlocks |
|
||||
| ------------ | ---------------------------------------------- | --------------------------------------- |
|
||||
| `shell` | `exec`, `process` | Run shell commands and manage processes |
|
||||
| `filesystem` | `write`, `edit`, `apply_patch` | File mutations (read is always allowed) |
|
||||
| `network` | `web_fetch`, `web_search` | Outbound HTTP requests |
|
||||
| `browser` | `browser` | Browser automation |
|
||||
| `sessions` | `sessions_spawn`, `sessions_send`, `subagents` | Cross-session orchestration |
|
||||
| `messaging` | `message` | Send messages to configured channels |
|
||||
| `scheduling` | `cron` | Schedule recurring jobs |
|
||||
|
||||
**Always allowed** — safe read-only or output-only tools, no capability required:
|
||||
|
||||
| Tool | Why safe |
|
||||
| ----------------------------------------------------- | --------------------------------- |
|
||||
| `read` | Read-only file access |
|
||||
| `memory_search`, `memory_get` | Read-only memory access |
|
||||
| `agents_list` | List agents (read-only) |
|
||||
| `sessions_list`, `sessions_history`, `session_status` | Session introspection (read-only) |
|
||||
| `canvas` | UI rendering (output-only) |
|
||||
| `image` | Image generation (output-only) |
|
||||
| `tts` | Text-to-speech (output-only) |
|
||||
|
||||
A community skill with no capabilities declared gets access only to the always-allowed tier. Declare capabilities in SKILL.md frontmatter:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
openclaw:
|
||||
capabilities: [shell, filesystem, network]
|
||||
```
|
||||
|
||||
## JSON output
|
||||
|
||||
Use `--json` for CI/policy checks:
|
||||
|
||||
@@ -18,175 +18,9 @@ Related:
|
||||
|
||||
## Commands
|
||||
|
||||
Quick command list:
|
||||
|
||||
```bash
|
||||
openclaw skills list
|
||||
openclaw skills list --eligible
|
||||
openclaw skills info <name>
|
||||
openclaw skills check
|
||||
openclaw skills check --json
|
||||
```
|
||||
|
||||
### `openclaw skills list`
|
||||
|
||||
List all skills with status, capabilities, and source.
|
||||
|
||||
```bash
|
||||
openclaw skills list # all skills
|
||||
openclaw skills list --eligible # only ready-to-use skills
|
||||
openclaw skills list --json # JSON output
|
||||
openclaw skills list -v # verbose (show missing requirements)
|
||||
```
|
||||
|
||||
Output columns: **Status** (`+ ready`, `x missing`, `x blocked`), **Skill** (name + capability icons), **Description**, **Source**.
|
||||
|
||||
Capability icons displayed next to skill names:
|
||||
|
||||
| Icon | Capability |
|
||||
| ---- | ---------------------------------------- |
|
||||
| `>_` | `shell` — run shell commands |
|
||||
| `📂` | `filesystem` — read/write files |
|
||||
| `🌐` | `network` — outbound HTTP |
|
||||
| `🔍` | `browser` — browser automation |
|
||||
| `⚡` | `sessions` — cross-session orchestration |
|
||||
| `✉️` | `messaging` — send channel messages |
|
||||
| `⏰` | `scheduling` — recurring jobs |
|
||||
|
||||
Skills blocked by security scanning show `x blocked` instead of `x missing`.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Skills (10/12 ready)
|
||||
|
||||
Status Skill Description Source
|
||||
+ ready git-autopush >_ 🌐 Automate git workflows openclaw-managed
|
||||
+ ready think Extended thinking bundled
|
||||
+ ready peekaboo 🔍 ⚡ Browser peek and screenshot bundled
|
||||
x missing summarize >_ Summarize with CLI tool bundled
|
||||
x blocked evil-injector >_ Totally harmless skill openclaw-managed
|
||||
- disabled old-skill Deprecated skill workspace
|
||||
```
|
||||
|
||||
With `-v` (verbose), the **Missing** column appears:
|
||||
|
||||
```
|
||||
Status Skill Description Source Missing
|
||||
+ ready git-autopush >_ 🌐 Automate git wor... openclaw-managed
|
||||
x missing summarize >_ Summarize with... bundled bins: summarize
|
||||
x blocked evil-injector >_ Totally harmless... openclaw-managed
|
||||
+ ready sketch-tool 🌐 >_ Generate sketches openclaw-managed
|
||||
```
|
||||
|
||||
### `openclaw skills info <name>`
|
||||
|
||||
Show detailed information about a single skill including security status.
|
||||
|
||||
```bash
|
||||
openclaw skills info git-helper
|
||||
openclaw skills info git-helper --json
|
||||
```
|
||||
|
||||
Displays: description, source, file path, capabilities (with descriptions), security scan results, requirements (met/unmet), and install options.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
git-autopush + Ready
|
||||
|
||||
Automate git commit, push, and PR workflows.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/git-autopush/SKILL.md
|
||||
Homepage https://github.com/example/git-autopush
|
||||
Primary env GH_TOKEN
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
🌐 network Make outbound HTTP requests
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
|
||||
Requirements
|
||||
bin git + ok
|
||||
bin gh + ok
|
||||
env GH_TOKEN + ok
|
||||
```
|
||||
|
||||
For a skill with missing requirements:
|
||||
|
||||
```
|
||||
summarize x Missing requirements
|
||||
|
||||
Summarize URLs and files using the summarize CLI.
|
||||
|
||||
Source bundled
|
||||
Path /opt/openclaw/skills/summarize/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan + clean
|
||||
|
||||
Requirements
|
||||
bin summarize x missing
|
||||
|
||||
Install options
|
||||
brew Install summarize (brew install summarize)
|
||||
```
|
||||
|
||||
For a skill blocked by scanning:
|
||||
|
||||
```
|
||||
evil-injector x Blocked (security)
|
||||
|
||||
Totally harmless skill.
|
||||
|
||||
Source openclaw-managed
|
||||
Path ~/.openclaw/skills/evil-injector/SKILL.md
|
||||
|
||||
Capabilities
|
||||
>_ shell Run shell commands
|
||||
|
||||
Security
|
||||
Scan [blocked] prompt injection detected
|
||||
```
|
||||
|
||||
### `openclaw skills check`
|
||||
|
||||
Security-focused overview of all skills.
|
||||
|
||||
```bash
|
||||
openclaw skills check
|
||||
openclaw skills check --json
|
||||
```
|
||||
|
||||
Shows: total/eligible/disabled/blocked/missing counts, capabilities requested by community skills, runtime policy restrictions, and scan result summary.
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Skills Status Check
|
||||
|
||||
Status Count
|
||||
Total 12
|
||||
Eligible 10
|
||||
Disabled 1
|
||||
Blocked (allowlist) 0
|
||||
Missing requirements 1
|
||||
|
||||
Community skill capabilities
|
||||
Icon Capability # Skills
|
||||
>_ shell 3 git-autopush, deploy-helper, node-runner
|
||||
📂 filesystem 2 git-autopush, file-editor
|
||||
🌐 network 2 git-autopush, sketch-tool
|
||||
|
||||
Scan results
|
||||
Result #
|
||||
Clean 11
|
||||
Warning 1
|
||||
Blocked 0
|
||||
```
|
||||
|
||||
@@ -98,9 +98,6 @@ 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.
|
||||
|
||||
@@ -22,7 +22,6 @@ Compaction **persists** in the session’s JSONL history.
|
||||
## Configuration
|
||||
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
@@ -55,18 +54,6 @@ Context window is model-specific. OpenClaw uses the model definition from the co
|
||||
|
||||
See [/concepts/session-pruning](/concepts/session-pruning) for pruning details.
|
||||
|
||||
## OpenAI server-side compaction
|
||||
|
||||
OpenClaw also supports OpenAI Responses server-side compaction hints for
|
||||
compatible direct OpenAI models. This is separate from local OpenClaw
|
||||
compaction and can run alongside it.
|
||||
|
||||
- Local compaction: OpenClaw summarizes and persists into session JSONL.
|
||||
- Server-side compaction: OpenAI compacts context on the provider side when
|
||||
`store` + `context_management` are enabled.
|
||||
|
||||
See [OpenAI provider](/providers/openai) for model params and overrides.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `/compact` when sessions feel stale or context is bloated.
|
||||
|
||||
@@ -70,8 +70,6 @@ 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
|
||||
{
|
||||
@@ -104,7 +102,6 @@ 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`
|
||||
|
||||
@@ -40,9 +40,8 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
|
||||
|
||||
Secrets are stored **per-agent**:
|
||||
|
||||
- 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)
|
||||
- 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`
|
||||
|
||||
Legacy import-only file (still supported, but not the main store):
|
||||
|
||||
@@ -50,8 +49,6 @@ 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:
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/providers/grammy",
|
||||
"destination": "/channels/telegram"
|
||||
"destination": "/channels/grammy"
|
||||
},
|
||||
{
|
||||
"source": "/providers/imessage",
|
||||
@@ -365,11 +365,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/grammy",
|
||||
"destination": "/channels/telegram"
|
||||
},
|
||||
{
|
||||
"source": "/channels/grammy",
|
||||
"destination": "/channels/telegram"
|
||||
"destination": "/channels/grammy"
|
||||
},
|
||||
{
|
||||
"source": "/group-messages",
|
||||
@@ -1144,8 +1140,6 @@
|
||||
"gateway/configuration-reference",
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"gateway/secrets",
|
||||
"gateway/secrets-plan-contract",
|
||||
"gateway/trusted-proxy-auth",
|
||||
"gateway/health",
|
||||
"gateway/heartbeat",
|
||||
@@ -1241,7 +1235,6 @@
|
||||
"cli/qr",
|
||||
"cli/reset",
|
||||
"cli/sandbox",
|
||||
"cli/secrets",
|
||||
"cli/security",
|
||||
"cli/sessions",
|
||||
"cli/setup",
|
||||
@@ -1275,7 +1268,12 @@
|
||||
},
|
||||
{
|
||||
"group": "Technical reference",
|
||||
"pages": ["reference/wizard", "reference/token-use", "reference/prompt-caching"]
|
||||
"pages": [
|
||||
"reference/wizard",
|
||||
"reference/token-use",
|
||||
"reference/prompt-caching",
|
||||
"channels/grammy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Concept internals",
|
||||
|
||||
@@ -638,7 +638,7 @@ Add independent ACP dispatch kill switch:
|
||||
|
||||
- `/focus <sessionKey>` continues to support ACP targets
|
||||
- `/unfocus` keeps current semantics
|
||||
- `/session idle` and `/session max-age` replace the old TTL override
|
||||
- `/session ttl` remains the top level TTL override
|
||||
|
||||
## Phased rollout
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ 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)
|
||||
|
||||
@@ -86,11 +85,6 @@ 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
|
||||
|
||||
@@ -65,30 +65,6 @@ Use `channels.modelByChannel` to pin specific channel IDs to a model. Values acc
|
||||
}
|
||||
```
|
||||
|
||||
### Channel defaults and heartbeat
|
||||
|
||||
Use `channels.defaults` for shared group-policy and heartbeat behavior across providers:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
defaults: {
|
||||
groupPolicy: "allowlist", // open | allowlist | disabled
|
||||
heartbeat: {
|
||||
showOk: false,
|
||||
showAlerts: true,
|
||||
useIndicator: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `channels.defaults.groupPolicy`: fallback group policy when a provider-level `groupPolicy` is unset.
|
||||
- `channels.defaults.heartbeat.showOk`: include healthy channel statuses in heartbeat output.
|
||||
- `channels.defaults.heartbeat.showAlerts`: include degraded/error statuses in heartbeat output.
|
||||
- `channels.defaults.heartbeat.useIndicator`: render compact indicator-style heartbeat output.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
WhatsApp runs through the gateway's web channel (Baileys Web). It starts automatically when a linked session exists.
|
||||
@@ -268,8 +244,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
ttlHours: 24,
|
||||
spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true })
|
||||
},
|
||||
voice: {
|
||||
@@ -304,9 +279,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
|
||||
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
|
||||
- `channels.discord.threadBindings` controls Discord thread-bound routing:
|
||||
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing)
|
||||
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
|
||||
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
|
||||
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session ttl`, and bound delivery/routing)
|
||||
- `ttlHours`: Discord override for auto-unfocus TTL (`0` disables)
|
||||
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
|
||||
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
|
||||
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
|
||||
@@ -347,7 +321,6 @@ 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).
|
||||
@@ -448,20 +421,12 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`.
|
||||
|
||||
Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with trigger prefix).
|
||||
|
||||
- `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes.
|
||||
- `channels.mattermost.requireMention`: require `@mention` before replying in channels.
|
||||
|
||||
### Signal
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
signal: {
|
||||
enabled: true,
|
||||
account: "+15555550123", // optional account binding
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
configWrites: true,
|
||||
reactionNotifications: "own", // off | own | all | allowlist
|
||||
reactionAllowlist: ["+15551234567", "uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
historyLimit: 50,
|
||||
@@ -472,29 +437,6 @@ Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message
|
||||
|
||||
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).
|
||||
|
||||
- `channels.signal.account`: pin channel startup to a specific Signal account identity.
|
||||
- `channels.signal.configWrites`: allow or deny Signal-initiated config writes.
|
||||
|
||||
### BlueBubbles
|
||||
|
||||
BlueBubbles is the recommended iMessage path (plugin-backed, configured under `channels.bluebubbles`).
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
// serverUrl, password, webhookPath, group controls, and advanced actions:
|
||||
// see /channels/bluebubbles
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Core key paths covered here: `channels.bluebubbles`, `channels.bluebubbles.dmPolicy`.
|
||||
- Full BlueBubbles channel configuration is documented in [BlueBubbles](/channels/bluebubbles).
|
||||
|
||||
### iMessage
|
||||
|
||||
OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
@@ -526,7 +468,6 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
- `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
|
||||
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
|
||||
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
|
||||
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
|
||||
|
||||
<Accordion title="iMessage SSH wrapper example">
|
||||
|
||||
@@ -537,52 +478,6 @@ exec ssh -T gateway-host imsg "$@"
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Microsoft Teams
|
||||
|
||||
Microsoft Teams is extension-backed and configured under `channels.msteams`.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
configWrites: true,
|
||||
// appId, appPassword, tenantId, webhook, team/channel policies:
|
||||
// see /channels/msteams
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Core key paths covered here: `channels.msteams`, `channels.msteams.configWrites`.
|
||||
- Full Teams config (credentials, webhook, DM/group policy, per-team/per-channel overrides) is documented in [Microsoft Teams](/channels/msteams).
|
||||
|
||||
### IRC
|
||||
|
||||
IRC is extension-backed and configured under `channels.irc`.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
configWrites: true,
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
password: "${IRC_NICKSERV_PASSWORD}",
|
||||
register: false,
|
||||
registerEmail: "bot@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Core key paths covered here: `channels.irc`, `channels.irc.dmPolicy`, `channels.irc.configWrites`, `channels.irc.nickserv.*`.
|
||||
- Full IRC channel configuration (host/port/TLS/channels/allowlists/mention gating) is documented in [IRC](/channels/irc).
|
||||
|
||||
### Multi-account (all channels)
|
||||
|
||||
Run multiple accounts per channel (each with its own `accountId`):
|
||||
@@ -614,11 +509,6 @@ Run multiple accounts per channel (each with its own `accountId`):
|
||||
- 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.
|
||||
|
||||
### Other extension channels
|
||||
|
||||
Many extension channels are configured as `channels.<id>` and documented in their dedicated channel pages (for example Feishu, Matrix, LINE, Nostr, Zalo, Nextcloud Talk, Synology Chat, and Twitch).
|
||||
See the full channel index: [Channels](/channels).
|
||||
|
||||
### Group chat mention gating
|
||||
|
||||
Group messages default to **require mention** (metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
|
||||
@@ -939,8 +829,6 @@ Periodic heartbeat runs.
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 6000,
|
||||
@@ -954,8 +842,6 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
|
||||
|
||||
### `agents.defaults.contextPruning`
|
||||
@@ -1380,8 +1266,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
},
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
idleHours: 24, // default inactivity auto-unfocus in hours (`0` disables)
|
||||
maxAgeHours: 0, // default hard max age in hours (`0` disables)
|
||||
ttlHours: 24, // default auto-unfocus TTL for thread-bound sessions (0 disables)
|
||||
},
|
||||
mainKey: "main", // legacy (runtime always uses "main")
|
||||
agentToAgent: { maxPingPongTurns: 5 },
|
||||
@@ -1418,8 +1303,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
- `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`.
|
||||
- **`threadBindings`**: global defaults for thread-bound session features.
|
||||
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
|
||||
- `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override)
|
||||
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
|
||||
- `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override)
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1865,25 +1749,6 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
|
||||
- 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 field details
|
||||
|
||||
- `models.mode`: provider catalog behavior (`merge` or `replace`).
|
||||
- `models.providers`: custom provider map keyed by provider id.
|
||||
- `models.providers.*.api`: request adapter (`openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai`, etc).
|
||||
- `models.providers.*.apiKey`: provider credential (prefer SecretRef/env substitution).
|
||||
- `models.providers.*.auth`: auth strategy (`api-key`, `token`, `oauth`, `aws-sdk`).
|
||||
- `models.providers.*.authHeader`: force credential transport in the `Authorization` header when required.
|
||||
- `models.providers.*.baseUrl`: upstream API base URL.
|
||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `models.bedrockDiscovery`: Bedrock auto-discovery settings root.
|
||||
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
|
||||
- `models.bedrockDiscovery.region`: AWS region for discovery.
|
||||
- `models.bedrockDiscovery.providerFilter`: optional provider-id filter for targeted discovery.
|
||||
- `models.bedrockDiscovery.refreshInterval`: polling interval for discovery refresh.
|
||||
- `models.bedrockDiscovery.defaultContextWindow`: fallback context window for discovered models.
|
||||
- `models.bedrockDiscovery.defaultMaxTokens`: fallback max output tokens for discovered models.
|
||||
|
||||
### Provider examples
|
||||
|
||||
<Accordion title="Cerebras (GLM 4.6 / 4.7)">
|
||||
@@ -2121,7 +1986,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
|
||||
},
|
||||
entries: {
|
||||
"nano-banana-pro": {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
|
||||
apiKey: "GEMINI_KEY_HERE",
|
||||
env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" },
|
||||
},
|
||||
peekaboo: { enabled: true },
|
||||
@@ -2133,7 +1998,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 (plaintext string or SecretRef object).
|
||||
- `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var.
|
||||
|
||||
---
|
||||
|
||||
@@ -2161,13 +2026,6 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- **Config changes require a gateway restart.**
|
||||
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
|
||||
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by plugin schema).
|
||||
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
|
||||
- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`.
|
||||
- Includes `source`, `spec`, `sourcePath`, `installPath`, `version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`, `shasum`, `resolvedAt`, `installedAt`.
|
||||
- Treat `plugins.installs.*` as managed state; prefer CLI commands over manual edits.
|
||||
|
||||
See [Plugins](/tools/plugin).
|
||||
|
||||
@@ -2290,18 +2148,17 @@ See [Plugins](/tools/plugin).
|
||||
- `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`.
|
||||
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
|
||||
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
|
||||
- `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
|
||||
- `gateway.auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
- `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
- `gateway.auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
|
||||
- `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
|
||||
- `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
|
||||
- Browser-origin WS auth attempts are always throttled with loopback exemption disabled (defense-in-depth against browser-based localhost brute force).
|
||||
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
|
||||
- `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` / `.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.
|
||||
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
|
||||
- `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).
|
||||
@@ -2527,73 +2384,6 @@ 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
|
||||
@@ -2611,11 +2401,8 @@ Notes:
|
||||
```
|
||||
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -2740,7 +2527,7 @@ See [Cron Jobs](/automation/cron-jobs).
|
||||
|
||||
## Media model template variables
|
||||
|
||||
Template placeholders expanded in `tools.media.models[].args`:
|
||||
Template placeholders expanded in `tools.media.*.models[].args`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
|
||||
@@ -184,8 +184,7 @@ When validation fails:
|
||||
dmScope: "per-channel-peer", // recommended for multi-user
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
idleHours: 24,
|
||||
maxAgeHours: 0,
|
||||
ttlHours: 24,
|
||||
},
|
||||
reset: {
|
||||
mode: "daily",
|
||||
@@ -197,7 +196,7 @@ When validation fails:
|
||||
```
|
||||
|
||||
- `dmScope`: `main` (shared) | `per-peer` | `per-channel-peer` | `per-account-channel-peer`
|
||||
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, `/session idle`, and `/session max-age`).
|
||||
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, and `/session ttl`).
|
||||
- See [Session Management](/concepts/session) for scoping, identity links, and send policy.
|
||||
- See [full reference](/gateway/configuration-reference#session) for all fields.
|
||||
|
||||
@@ -493,42 +492,6 @@ 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
|
||||
|
||||
@@ -16,12 +16,6 @@ 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
|
||||
@@ -100,7 +94,6 @@ openclaw gateway status --json
|
||||
openclaw gateway install
|
||||
openclaw gateway restart
|
||||
openclaw gateway stop
|
||||
openclaw secrets reload
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
@@ -216,32 +216,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
is enabled for break-glass use.
|
||||
- All connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
### Device auth migration diagnostics
|
||||
|
||||
For legacy clients that still use pre-challenge signing behavior, `connect` now returns
|
||||
`DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`.
|
||||
|
||||
Common migration failures:
|
||||
|
||||
| Message | details.code | details.reason | Meaning |
|
||||
| --------------------------- | -------------------------------- | ------------------------ | -------------------------------------------------- |
|
||||
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
|
||||
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
|
||||
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
|
||||
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
|
||||
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
|
||||
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
|
||||
|
||||
Migration target:
|
||||
|
||||
- Always wait for `connect.challenge`.
|
||||
- Sign the v2 payload that includes the server nonce.
|
||||
- Send the same nonce in `connect.params.device.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
|
||||
|
||||
- TLS is supported for WS connections.
|
||||
|
||||
@@ -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` -> `gateway.remote.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password`
|
||||
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
|
||||
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.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,8 +134,7 @@ 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` / `.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.token` is **only** for remote CLI calls — it does **not** enable local auth.
|
||||
- `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
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,386 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -206,7 +206,6 @@ Use this when auditing access or deciding what to back up:
|
||||
- `~/.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
|
||||
@@ -373,14 +372,6 @@ OpenClaw can refresh the skills list mid-session:
|
||||
- **Skills watcher**: changes to `SKILL.md` can update the skills snapshot on the next agent turn.
|
||||
- **Remote nodes**: connecting a macOS node can make macOS-only skills eligible (based on bin probing).
|
||||
|
||||
Community skills (installed from ClawHub) are subject to runtime security controls:
|
||||
|
||||
- **Capabilities**: skills declare required system access (`shell`, `filesystem`, `network`, `browser`, `sessions`, `messaging`, `scheduling`) in `metadata.openclaw.capabilities`. No capabilities means read-only metadata declaration; capability rollout is staged and currently used for visibility and policy checks.
|
||||
- **SKILL.md scanning**: content is scanned for prompt injection patterns, capability inflation, and boundary spoofing before entering the system prompt. Skills with critical findings are blocked from loading.
|
||||
- **Trust tiers**: `community` skills are enforced, while `builtin` and local/workspace skills are treated as trusted by default.
|
||||
- **Command dispatch gating**: community skills using `command-dispatch: tool` cannot dispatch to dangerous tools without declaring the matching capability.
|
||||
- **Audit logging**: security events are tagged with `category: "security"` and include session context.
|
||||
|
||||
Treat skill folders as **trusted code** and restrict who can modify them.
|
||||
|
||||
## The Threat Model
|
||||
@@ -694,10 +685,8 @@ Set a token so **all** WS clients must authenticate:
|
||||
|
||||
Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
|
||||
|
||||
Note: in local mode, OpenClaw still accepts `gateway.remote.token` /
|
||||
`gateway.remote.password` as fallback credentials when `gateway.auth.*` is
|
||||
unset. Prefer setting `gateway.auth.token` (or password mode) explicitly so
|
||||
auth behavior is clear.
|
||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
|
||||
|
||||
Local device pairing:
|
||||
@@ -771,9 +760,7 @@ 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, 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>/agent/auth-profiles.json`: API keys + OAuth tokens (imported from legacy `credentials/oauth.json`).
|
||||
- `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.
|
||||
@@ -1072,7 +1059,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`, and encrypted secrets payload values when used).
|
||||
3. Rotate provider/API credentials (WhatsApp creds, Slack/Discord tokens, model/API keys in `auth-profiles.json`).
|
||||
|
||||
### Audit
|
||||
|
||||
|
||||
@@ -80,27 +80,9 @@ Look for:
|
||||
Common signatures:
|
||||
|
||||
- `device identity required` → non-secure context or missing device auth.
|
||||
- `device nonce required` / `device nonce mismatch` → client is not completing the
|
||||
challenge-based device auth flow (`connect.challenge` + `device.nonce`).
|
||||
- `device signature invalid` / `device signature expired` → client signed the wrong
|
||||
payload (or stale timestamp) for the current handshake.
|
||||
- `unauthorized` / reconnect loop → token/password mismatch.
|
||||
- `gateway connect failed:` → wrong host/port/url target.
|
||||
|
||||
Device auth v2 migration check:
|
||||
|
||||
```bash
|
||||
openclaw --version
|
||||
openclaw doctor
|
||||
openclaw gateway status
|
||||
```
|
||||
|
||||
If logs show nonce/signature errors, update the connecting client and verify it:
|
||||
|
||||
1. waits for `connect.challenge`
|
||||
2. signs the challenge-bound payload
|
||||
3. sends `connect.params.device.nonce` with the same challenge nonce
|
||||
|
||||
Related:
|
||||
|
||||
- [/web/control-ui](/web/control-ui)
|
||||
|
||||
@@ -74,15 +74,6 @@ 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 |
|
||||
|
||||
@@ -1050,13 +1050,13 @@ Basic flow:
|
||||
- Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"` for persistent follow-up).
|
||||
- Or manually bind with `/focus <target>`.
|
||||
- Use `/agents` to inspect binding state.
|
||||
- Use `/session idle <duration|off>` and `/session max-age <duration|off>` to control auto-unfocus.
|
||||
- Use `/session ttl <duration|off>` to control auto-unfocus.
|
||||
- Use `/unfocus` to detach the thread.
|
||||
|
||||
Required config:
|
||||
|
||||
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`.
|
||||
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`.
|
||||
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`.
|
||||
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`.
|
||||
- Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`.
|
||||
|
||||
Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands).
|
||||
@@ -1291,17 +1291,16 @@ 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, 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) |
|
||||
| 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) |
|
||||
|
||||
Legacy single-agent path: `~/.openclaw/agent/*` (migrated by `openclaw doctor`).
|
||||
|
||||
@@ -1339,7 +1338,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, or encrypted secrets payloads).
|
||||
Do **not** commit anything under `~/.openclaw` (credentials, sessions, tokens).
|
||||
If you need a full restore, back up both the workspace and the state directory
|
||||
separately (see the migration question above).
|
||||
|
||||
@@ -1405,8 +1404,7 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
|
||||
|
||||
Notes:
|
||||
|
||||
- `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.
|
||||
- `gateway.remote.token` is for **remote CLI calls** only; it does not enable local gateway auth.
|
||||
- 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
|
||||
|
||||
@@ -101,23 +101,6 @@ Use this decision table:
|
||||
- Touching gateway networking / WS protocol / pairing: add `pnpm test:e2e`
|
||||
- Debugging “my bot is down” / provider-specific failures / tool calling: run a narrowed `pnpm test:live`
|
||||
|
||||
## Live: Android node capability sweep
|
||||
|
||||
- Test: `src/gateway/android-node.capabilities.live.test.ts`
|
||||
- Script: `pnpm android:test:integration`
|
||||
- Goal: invoke **every command currently advertised** by a connected Android node and assert command contract behavior.
|
||||
- Scope:
|
||||
- Preconditioned/manual setup (the suite does not install/run/pair the app).
|
||||
- Command-by-command gateway `node.invoke` validation for the selected Android node.
|
||||
- Required pre-setup:
|
||||
- Android app already connected + paired to the gateway.
|
||||
- App kept in foreground.
|
||||
- Permissions/capture consent granted for capabilities you expect to pass.
|
||||
- Optional target overrides:
|
||||
- `OPENCLAW_ANDROID_NODE_ID` or `OPENCLAW_ANDROID_NODE_NAME`.
|
||||
- `OPENCLAW_ANDROID_GATEWAY_URL` / `OPENCLAW_ANDROID_GATEWAY_TOKEN` / `OPENCLAW_ANDROID_GATEWAY_PASSWORD`.
|
||||
- Full Android setup details: [Android App](/platforms/android)
|
||||
|
||||
## Live: model smoke (profile keys)
|
||||
|
||||
Live tests are split into two layers so we can isolate failures:
|
||||
|
||||
@@ -85,7 +85,6 @@ 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
|
||||
|
||||
@@ -100,12 +100,6 @@ If permissions are missing, the app will prompt when possible; if denied, `camer
|
||||
|
||||
Like `canvas.*`, the Android node only allows `camera.*` commands in the **foreground**. Background invocations return `NODE_BACKGROUND_UNAVAILABLE`.
|
||||
|
||||
### Android commands (via Gateway `node.invoke`)
|
||||
|
||||
- `camera.list`
|
||||
- Response payload:
|
||||
- `devices`: array of `{ id, name, position, deviceType }`
|
||||
|
||||
### Payload guard
|
||||
|
||||
Photos are recompressed to keep the base64 payload under 5 MB.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user