mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 23:13:42 +08:00
Compare commits
5 Commits
vincentkoc
...
feature/OP
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b721ca85e | ||
|
|
511433fc18 | ||
|
|
8cd86226a8 | ||
|
|
fbb3c1aae2 | ||
|
|
26113f212c |
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:
|
||||
|
||||
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -240,10 +240,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/device-pair/**"
|
||||
"extensions: acpx":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
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({
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.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:
|
||||
@@ -419,23 +418,12 @@ jobs:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm protocol:check
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -507,12 +495,6 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: |
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure vitest JSON reports
|
||||
if: matrix.task == 'test'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
@@ -530,7 +512,7 @@ jobs:
|
||||
if: matrix.task == 'test'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}-shard${{ matrix.shard_index }}of${{ matrix.shard_count }}
|
||||
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
|
||||
path: |
|
||||
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
|
||||
${{ runner.temp }}/vitest-slowest.md
|
||||
|
||||
3
.github/workflows/docker-release.yml
vendored
3
.github/workflows/docker-release.yml
vendored
@@ -172,9 +172,6 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
version="${GITHUB_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
tags+=("${IMAGE}:latest")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No manifest tags resolved for ref ${GITHUB_REF}"
|
||||
|
||||
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).
|
||||
|
||||
198
CHANGELOG.md
198
CHANGELOG.md
@@ -2,203 +2,32 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.27
|
||||
|
||||
### 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.
|
||||
|
||||
### 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)
|
||||
- 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.
|
||||
|
||||
## 2026.2.25
|
||||
## 2026.2.25 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
|
||||
- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics.
|
||||
- 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.
|
||||
- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
|
||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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 `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808.
|
||||
- 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)
|
||||
- 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 `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl.
|
||||
- Cron/Message multi-account routing: honor explicit `delivery.accountId` for isolated cron delivery resolution, and when `message.send` omits `accountId`, fall back to the sending agent's bound channel account instead of defaulting to the global account. (#27015, #26975) Thanks @lbo728 and @stakeswky.
|
||||
- Gateway/Message media roots: thread `agentId` through gateway `send` RPC and prefer explicit `agentId` over session/default resolution so non-default agent workspace media sends no longer fail with `LocalMediaAccessError`; added regression coverage for agent precedence and blank-agent fallback. (#23249) Thanks @Sid-Qin.
|
||||
- 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.
|
||||
- Cron/Announce duplicate guard: track attempted announce/direct delivery separately from confirmed `delivered`, and suppress fallback main-session cron summaries when delivery was already attempted to avoid duplicate end-user sends in uncertain-ack paths. (#27018)
|
||||
- LINE/Lifecycle: keep LINE `startAccount` 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.
|
||||
- Discord/Gateway: capture and drain startup-time gateway `error` events before lifecycle listeners attach so early `Fatal Gateway error: 4014` closes surface as actionable intent guidance instead of uncaught gateway crashes. (#23832) Thanks @theotarr.
|
||||
- Discord/Inbound text: preserve embed `title` + `description` fallback text in message and forwarded snapshot parsing so embed titles are not silently dropped from agent input. (#26946) Thanks @stakeswky.
|
||||
- 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 `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
|
||||
- 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)
|
||||
- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
|
||||
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Models/Auth probes: map permanent auth failover reasons (`auth_permanent`, for example revoked keys) into probe auth status instead of `unknown`, so `openclaw models status --probe` reports actionable auth failures. (#25754) thanks @rrenamed.
|
||||
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
|
||||
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
|
||||
- Security/Gateway auth: require pairing for operator device-identity sessions authenticated with shared token auth so unpaired devices cannot self-assign operator scopes. Thanks @tdjackey for reporting.
|
||||
- Security/Gateway WebSocket auth: enforce origin checks for direct browser WebSocket clients beyond Control UI/Webchat, apply password-auth failure throttling to browser-origin loopback attempts (including localhost), and block silent auto-pairing for non-Control-UI browser clients to prevent cross-origin brute-force and session takeover chains. This ships in the next npm release (`2026.2.26`). Thanks @luz-oasis for reporting.
|
||||
- Security/Gateway trusted proxy: require `operator` role for the Control UI trusted-proxy pairing bypass so unpaired `node` sessions can no longer connect via `client.id=control-ui` and invoke node event methods. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/macOS beta onboarding: remove Anthropic OAuth sign-in and the legacy `oauth.json` onboarding path that exposed the PKCE verifier via OAuth `state`; this impacted the macOS beta onboarding path only. Anthropic subscription auth is now setup-token-only and will ship in the next npm release (`2026.2.26`). Thanks @zdi-disclosures for reporting.
|
||||
- Security/Microsoft Teams file consent: bind `fileConsent/invoke` upload acceptance/decline to the originating conversation before consuming pending uploads, preventing cross-conversation pending-file upload or cancellation via leaked `uploadId` values; includes regression coverage for match/mismatch invoke handling. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Gateway: harden `agents.files` path handling to block out-of-workspace symlink targets for `agents.files.get`/`agents.files.set`, keep in-workspace symlink targets supported, and add gateway regression coverage for both blocked escapes and allowed in-workspace symlinks. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace FS: reject hardlinked workspace file aliases in `tools.fs.workspaceOnly` and `tools.exec.applyPatch.workspaceOnly` boundary checks (including sandbox mount-root guards) to prevent out-of-workspace read/write via in-workspace hardlink paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser temp paths: harden trace/download output-path handling against symlink-root and symlink-parent escapes with realpath-based write-path checks plus secure fallback tmp-dir validation that fails closed on unsafe fallback links. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Browser uploads: revalidate upload paths at use-time in Playwright file-chooser and direct-input flows so missing/rebound paths are rejected before `setFiles`, with regression coverage for strict missing-path handling.
|
||||
- Security/Exec approvals: bind `system.run` approval matching to exact argv identity and preserve argv whitespace in rendered command text, preventing trailing-space executable path swaps from reusing a mismatched approval. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: harden approval-bound `system.run` execution on node hosts by rejecting symlink `cwd` paths and canonicalizing path-like executable argv before spawn, blocking mutable-cwd symlink retarget chains between approval and execution. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Signal: enforce DM/group authorization before reaction-only notification enqueue so unauthorized senders can no longer inject Signal reaction system events under `dmPolicy`/`groupPolicy`; reaction notifications now require channel access checks first. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Discord reactions: enforce DM policy/allowlist authorization before reaction-event system enqueue in direct messages; Discord reaction handling now also honors DM/group-DM enablement and guild `groupPolicy` channel gating to keep reaction ingress aligned with normal message preflight. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack reactions + pins: gate `reaction_*` and `pin_*` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress, with regression coverage for denied/allowed sender paths. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Slack member + message subtype events: gate `member_*` plus `message_changed`/`message_deleted`/`thread_broadcast` system-event enqueue through shared sender authorization so DM `dmPolicy`/`allowFrom` and channel `users` allowlists are enforced consistently for non-message ingress; message subtype system events now fail closed when sender identity is missing, with regression coverage. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram reactions: enforce `dmPolicy`/`allowFrom` and group allowlist authorization on `message_reaction` events before enqueueing reaction system events, preventing unauthorized reaction-triggered input in DMs and groups; ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
|
||||
- Security/Telegram group allowlist: fail closed for group sender authorization by removing DM pairing-store fallback from group allowlist evaluation; group sender access now requires explicit `groupAllowFrom` or per-group/per-topic `allowFrom`. (#25988) Thanks @bmendonca3.
|
||||
- Security/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.
|
||||
- Docker/Setup: when `OPENCLAW_IMAGE` is set to a non-local image, `docker-setup.sh` now pulls it instead of building locally, and fails fast if `OPENCLAW_DOCKER_APT_PACKAGES` is set in pull mode. (#7986) Thanks @ozbillwang.
|
||||
- Security/Nextcloud Talk: reject unsigned webhook traffic before full body reads, reducing unauthenticated request-body exposure, with auth-order regression coverage. (#26118) Thanks @bmendonca3.
|
||||
- Security/Nextcloud Talk: stop treating DM pairing-store entries as group allowlist senders, so group authorization remains bounded to configured group allowlists. (#26116) Thanks @bmendonca3.
|
||||
- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
|
||||
- Security/IRC: keep pairing-store approvals DM-only and out of IRC group allowlist authorization, with policy regression tests for allowlist resolution. (#26112) Thanks @bmendonca3.
|
||||
- Security/Microsoft Teams: isolate group allowlist and command authorization from DM pairing-store entries to prevent cross-context authorization bleed. (#26111) Thanks @bmendonca3.
|
||||
- Security/SSRF guard: classify IPv6 multicast literals (`ff00::/8`) as blocked/private-internal targets in shared SSRF IP checks, preventing multicast literals from bypassing URL-host preflight and DNS answer validation. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting.
|
||||
- Security/LINE: cap unsigned webhook body reads before auth/signature handling to bound unauthenticated body processing. (#26095) Thanks @bmendonca3.
|
||||
- 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)
|
||||
- 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.
|
||||
- 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.
|
||||
- Telegram/Markdown spoilers: keep valid `||spoiler||` pairs while leaving unmatched trailing `||` delimiters as literal text, avoiding false all-or-nothing spoiler suppression. (#26105) Thanks @Sid-Qin.
|
||||
- Hooks/Inbound metadata: include `guildId` and `channelName` in `message_received` metadata for both plugin and internal hook paths. (#26115) Thanks @davidrudduck.
|
||||
- Discord/Component auth: evaluate guild component interactions with command-gating authorizers so unauthorized users no longer get `CommandAuthorized: true` on modal/button events. (#26119) Thanks @bmendonca3.
|
||||
- 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.
|
||||
- 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.
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
- 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 `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
@@ -306,7 +135,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 +190,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!
|
||||
|
||||
@@ -23,9 +23,7 @@ COPY --chown=node:node patches ./patches
|
||||
COPY --chown=node:node scripts ./scripts
|
||||
|
||||
USER node
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
@@ -51,11 +49,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
|
||||
|
||||
78
PR_STATUS.md
Normal file
78
PR_STATUS.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# OpenClaw PR Submission Status
|
||||
|
||||
> Auto-maintained by agent team. Last updated: 2026-02-22
|
||||
|
||||
## PR Plan Overview
|
||||
|
||||
All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`.
|
||||
Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md).
|
||||
|
||||
## Duplicate Check
|
||||
|
||||
Before submission, each PR was cross-referenced against:
|
||||
|
||||
- 100+ open upstream PRs (as of 2026-02-22)
|
||||
- 50 recently merged PRs
|
||||
- 50+ open issues
|
||||
|
||||
No overlap found with existing PRs.
|
||||
|
||||
## PR Status Table
|
||||
|
||||
| # | Branch | Title | Type | Status | PR URL |
|
||||
| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- |
|
||||
| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) |
|
||||
| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) |
|
||||
| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) |
|
||||
| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) |
|
||||
| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) |
|
||||
| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) |
|
||||
| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) |
|
||||
| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) |
|
||||
| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) |
|
||||
| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) |
|
||||
| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) |
|
||||
|
||||
## Isolation Rules
|
||||
|
||||
- Each agent works on a separate git worktree branch
|
||||
- No two agents modify the same file
|
||||
- File ownership:
|
||||
- PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts`
|
||||
- PR 2: `src/agents/session-slug.ts`
|
||||
- PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts`
|
||||
- PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts`
|
||||
- PR 5: `src/signal/client.ts`, `src/imessage/client.ts`
|
||||
- PR 6: `src/media/store.ts`, `src/commands/signal-install.ts`
|
||||
- PR 7: `src/telegram/bot-message-dispatch.ts`
|
||||
- PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts`
|
||||
- PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts`
|
||||
- PR 10: `src/agents/skills-install-download.ts`
|
||||
- PR 11: `src/browser/extension-relay.ts`
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Batch 1 (PRs 1-4) — All CI Green
|
||||
|
||||
- PR 1: 17 tests pass, check/build/tests all green
|
||||
- PR 2: 3 tests pass, check/build/tests all green
|
||||
- PR 3: 45 tests pass (3 new), check/build/tests all green
|
||||
- PR 4: 12 tests pass, check/build/tests all green
|
||||
|
||||
### Batch 2 (PRs 5-7) — CI Running
|
||||
|
||||
- PR 5: 3 signal tests pass, check pass, awaiting full test suite
|
||||
- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite
|
||||
- PR 7: 47 tests pass (3 new), check pass, awaiting full suite
|
||||
|
||||
### Batch 3 (PRs 8-9) — All CI Green
|
||||
|
||||
- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test.
|
||||
- PR 8: 17/17 pass, check/build/tests/windows all green
|
||||
- PR 9: 18/18 pass, check/build/tests/windows all green
|
||||
|
||||
### Batch 4 (PRs 10-11) — All CI Green
|
||||
|
||||
- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9).
|
||||
- PR 10: 19/19 pass, check/build/tests/windows all green
|
||||
- PR 11: 20/20 pass, check/build/tests/windows all green
|
||||
@@ -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
|
||||
|
||||
182
appcast.xml
182
appcast.xml
@@ -209,106 +209,106 @@
|
||||
<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.24</title>
|
||||
<pubDate>Wed, 25 Feb 2026 02:59:30 +0000</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>14728</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.24</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.24</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>Auto-reply/Abort shortcuts: expand standalone stop phrases (<code>stop openclaw</code>, <code>stop action</code>, <code>stop run</code>, <code>stop agent</code>, <code>please stop</code>, and related variants), accept trailing punctuation (for example <code>STOP OPENCLAW!!!</code>), add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms), and treat exact <code>do not do that</code> as a stop trigger while preserving strict standalone matching. (#25103) Thanks @steipete and @vincentkoc.</li>
|
||||
<li>Android/App UX: ship a native four-step onboarding flow, move post-onboarding into a five-tab shell (Connect, Chat, Voice, Screen, Settings), add a full Connect setup/manual mode screen, and refresh Android chat/settings surfaces for the new navigation model.</li>
|
||||
<li>Talk/Gateway config: add provider-agnostic Talk configuration with legacy compatibility, and expose gateway Talk ElevenLabs config metadata for setup/status surfaces.</li>
|
||||
<li>Security/Audit: add <code>security.trust_model.multi_user_heuristic</code> to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (<code>sandbox.mode="all"</code>, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).</li>
|
||||
<li>Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping <code>@buape/carbon</code> pinned.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example <code>user:<id></code>, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.</li>
|
||||
<li><strong>BREAKING:</strong> Security/Sandbox: block Docker <code>network: "container:<id>"</code> namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set <code>agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true</code> (break-glass). Thanks @tdjackey for reporting.</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>Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (<code>channel</code>/<code>to</code>/<code>thread</code>) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.</li>
|
||||
<li>Security/Routing: fail closed for shared-session cross-channel replies by binding outbound target resolution to the current turn’s source channel metadata (instead of stale session route fallbacks), and wire those turn-source fields through gateway + command delivery planners with regression coverage. (#24571) Thanks @brandonwise.</li>
|
||||
<li>Heartbeat routing: prevent heartbeat leakage/spam into Discord and other direct-message destinations by blocking direct-chat heartbeat delivery targets and keeping blocked-delivery cron/exec prompts internal-only. (#25871)</li>
|
||||
<li>Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from <code>last</code> to <code>none</code> (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)</li>
|
||||
<li>Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.</li>
|
||||
<li>Cron/Heartbeat delivery: stop inheriting cached session <code>lastThreadId</code> for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.</li>
|
||||
<li>Messaging tool dedupe: treat originating channel metadata as authoritative for same-target <code>message.send</code> suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so <code>delivery-mirror</code> transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.</li>
|
||||
<li>Channels/Typing keepalive: refresh channel typing callbacks on a keepalive interval during long replies and clear keepalive timers on idle/cleanup across core + extension dispatcher callsites so typing indicators do not expire mid-inference. (#25886, #25882) Thanks @stakeswky.</li>
|
||||
<li>Agents/Model fallback: when a run is currently on a configured fallback model, keep traversing the configured fallback chain instead of collapsing straight to primary-only, preventing dead-end failures when primary stays in cooldown. (#25922, #25912) Thanks @Taskle.</li>
|
||||
<li>Gateway/Models: honor explicit <code>agents.defaults.models</code> allowlist refs even when bundled model catalog data is stale, synthesize missing allowlist entries in <code>models.list</code>, and allow <code>sessions.patch</code>/<code>/model</code> selection for those refs without false <code>model not allowed</code> errors. (#20291) Thanks @kensipe, @nikolasdehor, and @vincentkoc.</li>
|
||||
<li>Control UI/Agents: inherit <code>agents.defaults.model.fallbacks</code> in the Overview fallback input when no per-agent model entry exists, while preserving explicit per-agent fallback overrides (including empty lists). (#25729, #25710) Thanks @Suko.</li>
|
||||
<li>Automation/Subagent/Cron reliability: honor <code>ANNOUNCE_SKIP</code> in <code>sessions_spawn</code> completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include <code>cron</code> in the <code>coding</code> tool profile so <code>/tools/invoke</code> can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.</li>
|
||||
<li>Discord/Voice reliability: restore runtime DAVE dependency (<code>@snazzah/davey</code>), add configurable DAVE join options (<code>channels.discord.voice.daveEncryption</code> and <code>channels.discord.voice.decryptionFailureTolerance</code>), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032)</li>
|
||||
<li>Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all <code>block</code> payloads), fixing missing Discord replies in <code>channels.discord.streaming=block</code> mode. (#25839, #25836, #25792) Thanks @pewallin.</li>
|
||||
<li>Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire <code>messages.statusReactions.{emojis,timing}</code> into Discord reaction lifecycle control, and compact model-picker <code>custom_id</code> keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr.</li>
|
||||
<li>WhatsApp/Web reconnect: treat close status <code>440</code> as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.</li>
|
||||
<li>WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with <code>Reasoning:</code> before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)</li>
|
||||
<li>Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.</li>
|
||||
<li>Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.</li>
|
||||
<li>Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.</li>
|
||||
<li>Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram <code>autoSelectFamily</code> decisions so outbound <code>fetch</code> calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.</li>
|
||||
<li>Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.</li>
|
||||
<li>Android/Gateway auth: preserve Android gateway auth state across onboarding, use the native client id for operator sessions, retry with shared-token fallback after device-token auth failures, and avoid clearing tokens on transient connect errors.</li>
|
||||
<li>Slack/DM routing: treat <code>D*</code> channel IDs as direct messages even when Slack sends an incorrect <code>channel_type</code>, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.</li>
|
||||
<li>Zalo/Group policy: enforce sender authorization for group messages with <code>groupPolicy</code> + <code>groupAllowFrom</code> (fallback to <code>allowFrom</code>), default runtime group behavior to fail-closed allowlist, and block unauthorized non-command group messages before dispatch. Thanks @tdjackey for reporting.</li>
|
||||
<li>macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001.</li>
|
||||
<li>macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl.</li>
|
||||
<li>macOS/Voice wake routing: default forwarded voice-wake transcripts to the <code>webchat</code> channel (instead of ambiguous <code>last</code> routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18.</li>
|
||||
<li>macOS/Gateway launch: prefer an available <code>openclaw</code> binary before pnpm/node runtime fallback when resolving local gateway commands, so local startup no longer fails on hosts with broken runtime discovery. (#25512) Thanks @chilu18.</li>
|
||||
<li>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.</li>
|
||||
<li>macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos.</li>
|
||||
<li>Windows/Exec shell selection: prefer PowerShell 7 (<code>pwsh</code>) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing <code>&&</code> command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x.</li>
|
||||
<li>Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 <code>dev=0</code> stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false <code>Local media path is not safe to read</code> drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng.</li>
|
||||
<li>iMessage/Reasoning safety: harden iMessage echo suppression with outbound <code>messageId</code> matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb.</li>
|
||||
<li>Providers/OpenRouter/Auth profiles: bypass auth-profile cooldown/disable windows for OpenRouter, so provider failures no longer put OpenRouter profiles into local cooldown and stale legacy cooldown markers are ignored in fallback and status selection paths. (#25892) Thanks @alexanderatallah for raising this and @vincentkoc for the fix.</li>
|
||||
<li>Providers/Google reasoning: sanitize invalid negative <code>thinkingBudget</code> payloads for Gemini 3.1 requests by dropping <code>-1</code> budgets and mapping configured reasoning effort to <code>thinkingLevel</code>, preventing malformed reasoning payloads on <code>google-generative-ai</code>. (#25900)</li>
|
||||
<li>Providers/SiliconFlow: normalize <code>thinking="off"</code> to <code>thinking: null</code> for <code>Pro/*</code> model payloads to avoid provider-side 400 loops and misleading compaction retries. (#25435) Thanks @Zjianru.</li>
|
||||
<li>Models/Bedrock auth: normalize additional Bedrock provider aliases (<code>bedrock</code>, <code>aws-bedrock</code>, <code>aws_bedrock</code>, <code>amazon bedrock</code>) to canonical <code>amazon-bedrock</code>, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13.</li>
|
||||
<li>Models/Providers: preserve explicit user <code>reasoning</code> overrides when merging provider model config with built-in catalog metadata, so <code>reasoning: false</code> is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.</li>
|
||||
<li>Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false <code>pairing required</code> failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.</li>
|
||||
<li>CLI/Memory search: accept <code>--query <text></code> for <code>openclaw memory search</code> (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.</li>
|
||||
<li>CLI/Doctor: correct stale recovery hints to use valid commands (<code>openclaw gateway status --deep</code> and <code>openclaw configure --section model</code>). (#24485) Thanks @chilu18.</li>
|
||||
<li>Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.</li>
|
||||
<li>Doctor/Plugins: auto-enable now resolves third-party channel plugins by manifest plugin id (not channel id), preventing invalid <code>plugins.entries.<channelId></code> writes when ids differ. (#25275) Thanks @zerone0x.</li>
|
||||
<li>Config/Plugins: treat stale removed <code>google-antigravity-auth</code> plugin references as compatibility warnings (not hard validation errors) across <code>plugins.entries</code>, <code>plugins.allow</code>, <code>plugins.deny</code>, and <code>plugins.slots.memory</code>, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.</li>
|
||||
<li>Config/Meta: accept numeric <code>meta.lastTouchedAt</code> timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write <code>Date.now()</code> values. (#25491) Thanks @mcaxtr.</li>
|
||||
<li>Usage accounting: parse Moonshot/Kimi <code>cached_tokens</code> fields (including <code>prompt_tokens_details.cached_tokens</code>) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.</li>
|
||||
<li>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.</li>
|
||||
<li>Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit <code>status/code/http 402</code> detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.</li>
|
||||
<li>Sessions/Tool-result guard: avoid generating synthetic <code>toolResult</code> entries for assistant turns that ended with <code>stopReason: "aborted"</code> or <code>"error"</code>, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.</li>
|
||||
<li>Auto-reply/Reset hooks: guarantee native <code>/new</code> and <code>/reset</code> flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.</li>
|
||||
<li>Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.</li>
|
||||
<li>Sandbox/FS bridge tests: add regression coverage for dash-leading basenames to confirm sandbox file reads resolve to absolute container paths (and avoid shell-option misdiagnosis for dashed filenames). (#25891) Thanks @albertlieyingadrian.</li>
|
||||
<li>Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not <code>; </code> joins) to avoid POSIX <code>sh</code> <code>do;</code> syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.</li>
|
||||
<li>Sandbox/Config: preserve <code>dangerouslyAllowReservedContainerTargets</code> and <code>dangerouslyAllowExternalBindSources</code> during sandbox docker config resolution so explicit bind-mount break-glass overrides reach runtime validation. (#25410) Thanks @skyer-jian.</li>
|
||||
<li>Gateway/Security: enforce gateway auth for the exact <code>/api/channels</code> plugin root path (plus <code>/api/channels/</code> descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3.</li>
|
||||
<li>Exec approvals: treat bare allowlist <code>*</code> as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.</li>
|
||||
<li>iOS/Signing: improve <code>scripts/ios-team-id.sh</code> 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 <code>xcodebuild</code> output directories (<code>apps/ios/build</code>, <code>apps/shared/OpenClawKit/build</code>, <code>Swabble/build</code>). (#22773) Thanks @brianleach.</li>
|
||||
<li>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.</li>
|
||||
<li>Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (<code>LD_*</code>, <code>DYLD_*</code>, <code>SSLKEYLOGFILE</code>, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3.</li>
|
||||
<li>Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width <code>HOOK:...</code>) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3.</li>
|
||||
<li>Security/Voice Call: add Telnyx webhook replay detection and canonicalize replay-key signature encoding (Base64/Base64URL equivalent forms dedupe together), so duplicate signed webhook deliveries no longer re-trigger side effects. (#25832) Thanks @bmendonca3.</li>
|
||||
<li>Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host <code>os.tmpdir()</code> trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.</li>
|
||||
<li>Security/Message actions: enforce local media root checks for <code>sendAttachment</code> and <code>setGroupIcon</code> when <code>sandboxRoot</code> is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.</li>
|
||||
<li>Security/Telegram: enforce DM authorization before media download/write (including media groups) and move telegram inbound activity tracking after DM authorization, preventing unauthorized sender-triggered inbound media disk writes. Thanks @v8hid for reporting.</li>
|
||||
<li>Security/Workspace FS: normalize <code>@</code>-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Synology Chat: enforce fail-closed allowlist behavior for DM ingress so <code>dmPolicy: "allowlist"</code> with empty <code>allowedUserIds</code> rejects all senders instead of allowing unauthorized dispatch. (#25827) Thanks @bmendonca3 for the contribution and @tdjackey for reporting.</li>
|
||||
<li>Security/Native images: enforce <code>tools.fs.workspaceOnly</code> for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals: bind <code>system.run</code> command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only <code>rawCommand</code> mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec companion host: forward canonical <code>system.run</code> display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested <code>/usr/bin/env</code> chains cannot bypass shell-wrapper approval gating in <code>allowlist</code> + <code>ask=on-miss</code> mode. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec: limit default safe-bin trusted directories to immutable system paths (<code>/bin</code>, <code>/usr/bin</code>) and require explicit opt-in (<code>tools.exec.safeBinTrustedDirs</code>) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured <code>safeBins</code> resolve outside trusted dirs. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.</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.24/OpenClaw-2026.2.24.zip" length="23253502" type="application/octet-stream" sparkle:edSignature="acl6Y8HLA1Ar6WGVkgMQmDUm5F02tNwbjpDZe91LnqNWy68jAtVOplTnCXYPsiEcpHeykYhXS5cK5r0tN8v7AA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -34,40 +34,6 @@ cd apps/android
|
||||
|
||||
`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.
|
||||
|
||||
## Macrobenchmark (Startup + Frame Timing)
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./gradlew :benchmark:connectedDebugAndroidTest
|
||||
```
|
||||
|
||||
Reports are written under:
|
||||
|
||||
- `apps/android/benchmark/build/reports/androidTests/connected/`
|
||||
|
||||
## Perf CLI (low-noise)
|
||||
|
||||
Deterministic startup measurement + hotspot extraction with compact CLI output:
|
||||
|
||||
```bash
|
||||
cd apps/android
|
||||
./scripts/perf-startup-benchmark.sh
|
||||
./scripts/perf-startup-hotspots.sh
|
||||
```
|
||||
|
||||
Benchmark script behavior:
|
||||
|
||||
- Runs only `StartupMacrobenchmark#coldStartup` (10 iterations).
|
||||
- Prints median/min/max/COV in one line.
|
||||
- Writes timestamped snapshot JSON to `apps/android/benchmark/results/`.
|
||||
- Auto-compares with previous local snapshot (or pass explicit baseline: `--baseline <old-benchmarkData.json>`).
|
||||
|
||||
Hotspot script behavior:
|
||||
|
||||
- Ensures debug app installed, captures startup `simpleperf` data for `.MainActivity`.
|
||||
- Prints top DSOs, top symbols, and key app-path clues (Compose/MainActivity/WebView).
|
||||
- Writes raw `perf.data` path for deeper follow-up if needed.
|
||||
|
||||
## Run on a Real Android Phone (USB)
|
||||
|
||||
1) On phone, enable **Developer options** + **USB debugging**.
|
||||
@@ -150,56 +116,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 = 202602250
|
||||
versionName = "2026.2.25"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -146,7 +146,6 @@ dependencies {
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.3")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.3")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||
}
|
||||
|
||||
@@ -38,15 +38,6 @@
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package ai.openclaw.android
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
@@ -23,6 +25,9 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
WebView.setWebContentsDebuggingEnabled(isDebuggable)
|
||||
NodeForegroundService.start(this)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||
viewModel.camera.attachLifecycleOwner(this)
|
||||
@@ -50,9 +55,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep startup path lean: start foreground service after first frame.
|
||||
window.decorView.post { NodeForegroundService.start(this) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
||||
@@ -2,12 +2,23 @@ package ai.openclaw.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import java.security.Security
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Register Bouncy Castle as highest-priority provider for Ed25519 support
|
||||
try {
|
||||
val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
|
||||
.getDeclaredConstructor().newInstance() as java.security.Provider
|
||||
Security.removeProvider("BC")
|
||||
Security.insertProviderAt(bcProvider, 1)
|
||||
} catch (it: Throwable) {
|
||||
Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
|
||||
@@ -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,14 +92,6 @@ class NodeRuntime(context: Context) {
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val deviceHandler: DeviceHandler = DeviceHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler = NotificationsHandler(
|
||||
appContext = appContext,
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
@@ -129,8 +123,6 @@ class NodeRuntime(context: Context) {
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
deviceHandler = deviceHandler,
|
||||
notificationsHandler = notificationsHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
@@ -139,9 +131,6 @@ class NodeRuntime(context: Context) {
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
_canvasRehydratePending.value = false
|
||||
@@ -461,10 +450,6 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setVoiceWakeMode(VoiceWakeMode.Off)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
prefs.loadGatewayToken()
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
micCapture.setMicEnabled(enabled)
|
||||
|
||||
@@ -20,21 +20,19 @@ class SecurePrefs(context: Context) {
|
||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
||||
private const val displayNameKey = "node.displayName"
|
||||
private const val voiceWakeModeKey = "voiceWake.mode"
|
||||
private const val plainPrefsName = "openclaw.node"
|
||||
private const val securePrefsName = "openclaw.node.secure"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val plainPrefs: SharedPreferences =
|
||||
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
|
||||
|
||||
private val masterKey by lazy {
|
||||
MasterKey.Builder(appContext)
|
||||
private val masterKey =
|
||||
MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
createPrefs(appContext, "openclaw.node.secure")
|
||||
}
|
||||
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
|
||||
|
||||
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||
val instanceId: StateFlow<String> = _instanceId
|
||||
@@ -43,51 +41,52 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(loadOrMigrateDisplayName(context = context))
|
||||
val displayName: StateFlow<String> = _displayName
|
||||
|
||||
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
|
||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||
|
||||
private val _locationMode =
|
||||
MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
|
||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
||||
|
||||
private val _locationPreciseEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
|
||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
||||
|
||||
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
|
||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||
|
||||
private val _manualEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.enabled", false))
|
||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||
|
||||
private val _manualHost =
|
||||
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
|
||||
MutableStateFlow(prefs.getString("gateway.manual.host", "") ?: "")
|
||||
val manualHost: StateFlow<String> = _manualHost
|
||||
|
||||
private val _manualPort =
|
||||
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
|
||||
MutableStateFlow(prefs.getInt("gateway.manual.port", 18789))
|
||||
val manualPort: StateFlow<Int> = _manualPort
|
||||
|
||||
private val _manualTls =
|
||||
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _gatewayToken = MutableStateFlow("")
|
||||
private val _gatewayToken =
|
||||
MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
|
||||
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||
|
||||
private val _onboardingCompleted =
|
||||
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
||||
MutableStateFlow(prefs.getBoolean("onboarding.completed", false))
|
||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(
|
||||
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
)
|
||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||
|
||||
private val _canvasDebugStatusEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
MutableStateFlow(prefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||
@@ -96,65 +95,65 @@ class SecurePrefs(context: Context) {
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
|
||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
_lastDiscoveredStableId.value = trimmed
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString(displayNameKey, trimmed) }
|
||||
prefs.edit { putString(displayNameKey, trimmed) }
|
||||
_displayName.value = trimmed
|
||||
}
|
||||
|
||||
fun setCameraEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("camera.enabled", value) }
|
||||
prefs.edit { putBoolean("camera.enabled", value) }
|
||||
_cameraEnabled.value = value
|
||||
}
|
||||
|
||||
fun setLocationMode(mode: LocationMode) {
|
||||
plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
||||
_locationMode.value = mode
|
||||
}
|
||||
|
||||
fun setLocationPreciseEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
||||
_locationPreciseEnabled.value = value
|
||||
}
|
||||
|
||||
fun setPreventSleep(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||
_preventSleep.value = value
|
||||
}
|
||||
|
||||
fun setManualEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
prefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||
_manualEnabled.value = value
|
||||
}
|
||||
|
||||
fun setManualHost(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
prefs.edit { putString("gateway.manual.host", trimmed) }
|
||||
_manualHost.value = trimmed
|
||||
}
|
||||
|
||||
fun setManualPort(value: Int) {
|
||||
plainPrefs.edit { putInt("gateway.manual.port", value) }
|
||||
prefs.edit { putInt("gateway.manual.port", value) }
|
||||
_manualPort.value = value
|
||||
}
|
||||
|
||||
fun setManualTls(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
prefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
val trimmed = value.trim()
|
||||
securePrefs.edit { putString("gateway.manual.token", trimmed) }
|
||||
prefs.edit(commit = true) { putString("gateway.manual.token", trimmed) }
|
||||
_gatewayToken.value = trimmed
|
||||
}
|
||||
|
||||
@@ -163,67 +162,62 @@ class SecurePrefs(context: Context) {
|
||||
}
|
||||
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
||||
prefs.edit { putBoolean("onboarding.completed", value) }
|
||||
_onboardingCompleted.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadGatewayToken(): String? {
|
||||
val manual =
|
||||
_gatewayToken.value.trim().ifEmpty {
|
||||
val stored = securePrefs.getString("gateway.manual.token", null)?.trim().orEmpty()
|
||||
if (stored.isNotEmpty()) _gatewayToken.value = stored
|
||||
stored
|
||||
}
|
||||
val manual = _gatewayToken.value.trim()
|
||||
if (manual.isNotEmpty()) return manual
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, token.trim()) }
|
||||
prefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayPassword(): String? {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayPassword(password: String) {
|
||||
val key = "gateway.password.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, password.trim()) }
|
||||
prefs.edit { putString(key, password.trim()) }
|
||||
}
|
||||
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
||||
val key = "gateway.tls.$stableId"
|
||||
plainPrefs.edit { putString(key, fingerprint.trim()) }
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return securePrefs.getString(key, null)
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
securePrefs.edit { putString(key, value) }
|
||||
prefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
securePrefs.edit { remove(key) }
|
||||
prefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun createSecurePrefs(context: Context, name: String): SharedPreferences {
|
||||
private fun createPrefs(context: Context, name: String): SharedPreferences {
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
name,
|
||||
@@ -234,21 +228,21 @@ class SecurePrefs(context: Context) {
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
plainPrefs.edit { putString("node.instanceId", fresh) }
|
||||
prefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
}
|
||||
|
||||
private fun loadOrMigrateDisplayName(context: Context): String {
|
||||
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
val existing = prefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
plainPrefs.edit { putString(displayNameKey, resolved) }
|
||||
prefs.edit { putString(displayNameKey, resolved) }
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -256,34 +250,34 @@ class SecurePrefs(context: Context) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
plainPrefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
prefs.edit { putString("voiceWake.triggerWords", encoded) }
|
||||
_wakeWords.value = sanitized
|
||||
}
|
||||
|
||||
fun setVoiceWakeMode(mode: VoiceWakeMode) {
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
prefs.edit { putString(voiceWakeModeKey, mode.rawValue) }
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("talk.enabled", value) }
|
||||
prefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
prefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private fun loadWakeWords(): List<String> {
|
||||
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
val raw = prefs.getString("voiceWake.triggerWords", null)?.trim()
|
||||
if (raw.isNullOrEmpty()) return defaultWakeWords
|
||||
return try {
|
||||
val element = json.parseToJsonElement(raw)
|
||||
@@ -301,4 +295,5 @@ class SecurePrefs(context: Context) {
|
||||
defaultWakeWords
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,13 @@ package ai.openclaw.android.gateway
|
||||
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadToken(deviceId: String, role: String): String?
|
||||
fun saveToken(deviceId: String, role: String, token: String)
|
||||
}
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package ai.openclaw.android.gateway
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.Signature
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -18,26 +22,21 @@ data class DeviceIdentity(
|
||||
class DeviceIdentityStore(context: Context) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
@Volatile private var cachedIdentity: DeviceIdentity? = null
|
||||
|
||||
@Synchronized
|
||||
fun loadOrCreate(): DeviceIdentity {
|
||||
cachedIdentity?.let { return it }
|
||||
val existing = load()
|
||||
if (existing != null) {
|
||||
val derived = deriveDeviceId(existing.publicKeyRawBase64)
|
||||
if (derived != null && derived != existing.deviceId) {
|
||||
val updated = existing.copy(deviceId = derived)
|
||||
save(updated)
|
||||
cachedIdentity = updated
|
||||
return updated
|
||||
}
|
||||
cachedIdentity = existing
|
||||
return existing
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
cachedIdentity = fresh
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -152,16 +151,22 @@ class DeviceIdentityStore(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
|
||||
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
|
||||
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
|
||||
) {
|
||||
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
|
||||
}
|
||||
return spki
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = CharArray(digest.size * 2)
|
||||
var i = 0
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
val v = byte.toInt() and 0xff
|
||||
out[i++] = HEX[v ushr 4]
|
||||
out[i++] = HEX[v and 0x0f]
|
||||
out.append(String.format("%02x", byte))
|
||||
}
|
||||
return String(out)
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun base64UrlEncode(data: ByteArray): String {
|
||||
@@ -169,6 +174,9 @@ class DeviceIdentityStore(context: Context) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
private val ED25519_SPKI_PREFIX =
|
||||
byteArrayOf(
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ data class GatewayConnectOptions(
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
@@ -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(
|
||||
@@ -241,7 +200,9 @@ class GatewaySession(
|
||||
suspend fun connect() {
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).build()
|
||||
val httpScheme = if (tls != null) "https" else "http"
|
||||
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).header("Origin", origin).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
@@ -413,7 +374,7 @@ class GatewaySession(
|
||||
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
DeviceAuthPayload.buildV3(
|
||||
buildDeviceAuthPayload(
|
||||
deviceId = identity.deviceId,
|
||||
clientId = client.id,
|
||||
clientMode = client.mode,
|
||||
@@ -422,8 +383,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 +501,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,20 +527,24 @@ 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}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = err::class.java.simpleName)
|
||||
return InvokeResult.error(code = parsed.code, message = parsed.message)
|
||||
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||
val parts = msg.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||
}
|
||||
}
|
||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||
}
|
||||
|
||||
private fun failPending() {
|
||||
@@ -634,6 +592,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 +722,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,39 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
data class ParsedInvokeError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val hadExplicitCode: Boolean,
|
||||
) {
|
||||
val prefixedMessage: String
|
||||
get() = "$code: $message"
|
||||
}
|
||||
|
||||
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
|
||||
}
|
||||
|
||||
val parts = trimmed.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val code = parts[0].trim()
|
||||
val rest = parts[1].trim()
|
||||
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||
return ParsedInvokeError(
|
||||
code = code,
|
||||
message = rest.ifEmpty { trimmed },
|
||||
hadExplicitCode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
|
||||
}
|
||||
|
||||
fun parseInvokeErrorFromThrowable(
|
||||
err: Throwable,
|
||||
fallbackMessage: String = "error",
|
||||
): ParsedInvokeError {
|
||||
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
|
||||
return parseInvokeErrorMessage(raw)
|
||||
}
|
||||
@@ -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.5).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 800
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
@@ -74,18 +80,37 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = cameraEnabled(),
|
||||
locationEnabled = locationMode() != LocationMode.Off,
|
||||
smsAvailable = smsAvailable(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
add(OpenClawCanvasCommand.Hide.rawValue)
|
||||
add(OpenClawCanvasCommand.Navigate.rawValue)
|
||||
add(OpenClawCanvasCommand.Eval.rawValue)
|
||||
add(OpenClawCanvasCommand.Snapshot.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Push.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Reset.rawValue)
|
||||
add(OpenClawScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled()) {
|
||||
add(OpenClawCameraCommand.Snap.rawValue)
|
||||
add(OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (smsAvailable()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
add("debug.logs")
|
||||
add("debug.ed25519")
|
||||
}
|
||||
add("app.update")
|
||||
}
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
add(OpenClawCapability.Device.rawValue)
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
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
|
||||
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
|
||||
}
|
||||
|
||||
data class DeviceNotificationEntry(
|
||||
val key: String,
|
||||
val packageName: String,
|
||||
val title: String?,
|
||||
val text: String?,
|
||||
val subText: String?,
|
||||
val category: String?,
|
||||
val channelId: String?,
|
||||
val postTimeMs: Long,
|
||||
val isOngoing: Boolean,
|
||||
val isClearable: Boolean,
|
||||
)
|
||||
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
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
|
||||
private val byKey = LinkedHashMap<String, DeviceNotificationEntry>()
|
||||
|
||||
fun replace(entries: List<DeviceNotificationEntry>) {
|
||||
synchronized(lock) {
|
||||
byKey.clear()
|
||||
for (entry in entries) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsert(entry: DeviceNotificationEntry) {
|
||||
synchronized(lock) {
|
||||
byKey[entry.key] = entry
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
synchronized(lock) {
|
||||
byKey.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun setConnected(value: Boolean) {
|
||||
synchronized(lock) {
|
||||
connected = value
|
||||
if (!value) {
|
||||
byKey.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(enabled: Boolean): DeviceNotificationSnapshot {
|
||||
val (isConnected, entries) =
|
||||
synchronized(lock) {
|
||||
connected to byKey.values.sortedByDescending { it.postTimeMs }
|
||||
}
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = enabled,
|
||||
connected = isConnected,
|
||||
notifications = entries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
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
|
||||
DeviceNotificationStore.upsert(entry)
|
||||
}
|
||||
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification?) {
|
||||
super.onNotificationRemoved(sbn)
|
||||
val key = sbn?.key ?: return
|
||||
DeviceNotificationStore.remove(key)
|
||||
}
|
||||
|
||||
private fun refreshActiveNotifications() {
|
||||
val entries =
|
||||
runCatching {
|
||||
activeNotifications
|
||||
?.mapNotNull { it.toEntry() }
|
||||
?: emptyList()
|
||||
}.getOrElse { emptyList() }
|
||||
DeviceNotificationStore.replace(entries)
|
||||
}
|
||||
|
||||
private fun StatusBarNotification.toEntry(): DeviceNotificationEntry {
|
||||
val extras = notification.extras
|
||||
val keyValue = key.takeIf { it.isNotBlank() } ?: "$packageName:$id:$postTime"
|
||||
val title = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TITLE))
|
||||
val body =
|
||||
sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_BIG_TEXT))
|
||||
?: sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_TEXT))
|
||||
val subText = sanitizeNotificationText(extras?.getCharSequence(Notification.EXTRA_SUB_TEXT))
|
||||
return DeviceNotificationEntry(
|
||||
key = keyValue,
|
||||
packageName = packageName,
|
||||
title = title,
|
||||
text = body,
|
||||
subText = subText,
|
||||
category = notification.category?.trim()?.ifEmpty { null },
|
||||
channelId = notification.channelId?.trim()?.ifEmpty { null },
|
||||
postTimeMs = postTime,
|
||||
isOngoing = isOngoing,
|
||||
isClearable = isClearable,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
|
||||
private fun serviceComponent(context: Context): ComponentName {
|
||||
return ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
}
|
||||
|
||||
fun snapshot(context: Context, enabled: Boolean = isAccessEnabled(context)): DeviceNotificationSnapshot {
|
||||
return DeviceNotificationStore.snapshot(enabled = enabled)
|
||||
}
|
||||
|
||||
fun requestServiceRebind(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
|
||||
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"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
LocationEnabled,
|
||||
SmsAvailable,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
val availability: InvokeCommandAvailability = InvokeCommandAvailability.Always,
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Present.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Hide.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Navigate.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Eval.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasCommand.Snapshot.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Push.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.PushJSONL.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCanvasA2UICommand.Reset.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawScreenCommand.Record.rawValue,
|
||||
requiresForeground = true,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.List.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Snap.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawCameraCommand.Clip.rawValue,
|
||||
requiresForeground = true,
|
||||
availability = InvokeCommandAvailability.CameraEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawLocationCommand.Get.rawValue,
|
||||
availability = InvokeCommandAvailability.LocationEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Status.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Info.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = 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,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.logs",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = "debug.ed25519",
|
||||
availability = InvokeCommandAvailability.DebugBuild,
|
||||
),
|
||||
InvokeCommandSpec(name = "app.update"),
|
||||
)
|
||||
|
||||
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
fun advertisedCommands(
|
||||
cameraEnabled: Boolean,
|
||||
locationEnabled: Boolean,
|
||||
smsAvailable: Boolean,
|
||||
debugBuild: Boolean,
|
||||
): List<String> {
|
||||
return all
|
||||
.filter { spec ->
|
||||
when (spec.availability) {
|
||||
InvokeCommandAvailability.Always -> true
|
||||
InvokeCommandAvailability.CameraEnabled -> cameraEnabled
|
||||
InvokeCommandAvailability.LocationEnabled -> locationEnabled
|
||||
InvokeCommandAvailability.SmsAvailable -> smsAvailable
|
||||
InvokeCommandAvailability.DebugBuild -> debugBuild
|
||||
}
|
||||
}
|
||||
.map { it.name }
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
@@ -14,8 +12,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,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
@@ -24,26 +20,40 @@ class InvokeDispatcher(
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
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,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val spec =
|
||||
InvokeCommandRegistry.find(command)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
// Check foreground requirement for canvas/camera/screen commands
|
||||
if (
|
||||
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
if (spec.requiresForeground && !isForeground()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check camera enabled
|
||||
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
// Check location enabled
|
||||
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
availabilityError(spec.availability)?.let { return it }
|
||||
|
||||
return when (command) {
|
||||
// Canvas commands
|
||||
@@ -65,33 +75,53 @@ class InvokeDispatcher(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
withCanvasAvailable {
|
||||
val result = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
OpenClawCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
withCanvasAvailable {
|
||||
val base64 =
|
||||
val base64 =
|
||||
try {
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
|
||||
// A2UI commands
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue ->
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
onCanvasA2uiReset()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
@@ -102,34 +132,31 @@ class InvokeDispatcher(
|
||||
message = err.message ?: "invalid A2UI payload"
|
||||
)
|
||||
}
|
||||
withReadyA2ui {
|
||||
withCanvasAvailable {
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
onCanvasA2uiPush()
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -143,93 +170,11 @@ class InvokeDispatcher(
|
||||
// App update
|
||||
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
|
||||
|
||||
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withReadyA2ui(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
var 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",
|
||||
else ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
private suspend fun withCanvasAvailable(
|
||||
block: suspend () -> GatewaySession.InvokeResult,
|
||||
): GatewaySession.InvokeResult {
|
||||
return try {
|
||||
block()
|
||||
} catch (_: Throwable) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun availabilityError(availability: InvokeCommandAvailability): GatewaySession.InvokeResult? {
|
||||
return when (availability) {
|
||||
InvokeCommandAvailability.Always -> null
|
||||
InvokeCommandAvailability.CameraEnabled ->
|
||||
if (cameraEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.LocationEnabled ->
|
||||
if (locationEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.SmsAvailable ->
|
||||
if (smsAvailable()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "SMS_UNAVAILABLE",
|
||||
message = "SMS_UNAVAILABLE: SMS not available on this device",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -38,9 +37,14 @@ fun parseHexColorArgb(raw: String?): Long? {
|
||||
}
|
||||
|
||||
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
|
||||
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
|
||||
return parsed.code to message
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
fun normalizeMainKey(raw: String?): String? {
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
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 {
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
|
||||
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
if (!enabled) {
|
||||
return DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
)
|
||||
}
|
||||
return DeviceNotificationListenerService.snapshot(context, enabled = true)
|
||||
}
|
||||
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
|
||||
override fun executeAction(context: Context, request: NotificationActionRequest): NotificationActionResult {
|
||||
return DeviceNotificationListenerService.executeAction(context, request)
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val stateProvider: NotificationsStateProvider,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = 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
|
||||
}
|
||||
|
||||
private fun snapshotPayloadJson(snapshot: DeviceNotificationSnapshot): String {
|
||||
return buildJsonObject {
|
||||
put("enabled", JsonPrimitive(snapshot.enabled))
|
||||
put("connected", JsonPrimitive(snapshot.connected))
|
||||
put("count", JsonPrimitive(snapshot.notifications.size))
|
||||
put(
|
||||
"notifications",
|
||||
JsonArray(
|
||||
snapshot.notifications.map { entry ->
|
||||
buildJsonObject {
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
}
|
||||
|
||||
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,
|
||||
stateProvider: NotificationsStateProvider,
|
||||
): NotificationsHandler = NotificationsHandler(appContext = appContext, stateProvider = stateProvider)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
;
|
||||
@@ -71,25 +69,3 @@ enum class OpenClawLocationCommand(val rawValue: String) {
|
||||
const val NamespacePrefix: String = "location."
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const val NamespacePrefix: String = "notifications."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@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,33 +0,0 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeErrorParserTest {
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_parsesUppercaseCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED", parsed.code)
|
||||
assertEquals("grant Camera permission", parsed.message)
|
||||
assertTrue(parsed.hadExplicitCode)
|
||||
assertEquals("CAMERA_PERMISSION_REQUIRED: grant Camera permission", parsed.prefixedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorMessage_rejectsNonCanonicalCodePrefix() {
|
||||
val parsed = parseInvokeErrorMessage("IllegalStateException: boom")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("IllegalStateException: boom", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseInvokeErrorFromThrowable_usesFallbackWhenMessageMissing() {
|
||||
val parsed = parseInvokeErrorFromThrowable(IllegalStateException(), fallbackMessage = "fallback")
|
||||
assertEquals("UNAVAILABLE", parsed.code)
|
||||
assertEquals("fallback", parsed.message)
|
||||
assertFalse(parsed.hadExplicitCode)
|
||||
}
|
||||
}
|
||||
@@ -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,64 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
debugBuild = false,
|
||||
)
|
||||
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(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"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
debugBuild = true,
|
||||
)
|
||||
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(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"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NotificationsHandlerTest {
|
||||
@Test
|
||||
fun notificationsListReturnsStatusPayloadWhenDisabled() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = false,
|
||||
connected = false,
|
||||
notifications = emptyList(),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertFalse(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(0, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListRequestsRebindWhenEnabledButDisconnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = false,
|
||||
notifications = listOf(sampleEntry("n1")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertFalse(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(1, payload.getValue("notifications").jsonArray.size)
|
||||
assertEquals(1, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationsListDoesNotRequestRebindWhenConnected() =
|
||||
runTest {
|
||||
val provider =
|
||||
FakeNotificationsStateProvider(
|
||||
DeviceNotificationSnapshot(
|
||||
enabled = true,
|
||||
connected = true,
|
||||
notifications = listOf(sampleEntry("n2")),
|
||||
),
|
||||
)
|
||||
val handler = NotificationsHandler.forTesting(appContext = appContext(), stateProvider = provider)
|
||||
|
||||
val result = handler.handleNotificationsList(null)
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertNull(result.error)
|
||||
val payload = parsePayload(result)
|
||||
assertTrue(payload.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertTrue(payload.getValue("connected").jsonPrimitive.boolean)
|
||||
assertEquals(1, payload.getValue("count").jsonPrimitive.int)
|
||||
assertEquals(0, provider.rebindRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun 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))
|
||||
assertNull(sanitizeNotificationText(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeNotificationTextTrimsAndTruncates() {
|
||||
val value = " ${"x".repeat(600)} "
|
||||
val sanitized = sanitizeNotificationText(value)
|
||||
|
||||
assertEquals(512, sanitized?.length)
|
||||
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
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun sampleEntry(key: String): DeviceNotificationEntry =
|
||||
DeviceNotificationEntry(
|
||||
key = key,
|
||||
packageName = "com.example.app",
|
||||
title = "Title",
|
||||
text = "Text",
|
||||
subText = null,
|
||||
category = null,
|
||||
channelId = null,
|
||||
postTimeMs = 123L,
|
||||
isOngoing = false,
|
||||
isClearable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeNotificationsStateProvider(
|
||||
private val snapshot: DeviceNotificationSnapshot,
|
||||
) : NotificationsStateProvider {
|
||||
var rebindRequests: Int = 0
|
||||
private set
|
||||
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,34 +26,10 @@ 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
|
||||
fun screenCommandsUseStableStrings() {
|
||||
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.test")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.android.benchmark"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE,EMULATOR"
|
||||
}
|
||||
|
||||
targetProjectPath = ":app"
|
||||
experimentalProperties["android.experimental.self-instrumenting"] = true
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
|
||||
implementation("androidx.test.ext:junit:1.2.1")
|
||||
implementation("androidx.test.uiautomator:uiautomator:2.4.0-alpha06")
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package ai.openclaw.android.benchmark
|
||||
|
||||
import androidx.benchmark.macro.CompilationMode
|
||||
import androidx.benchmark.macro.FrameTimingMetric
|
||||
import androidx.benchmark.macro.StartupMode
|
||||
import androidx.benchmark.macro.StartupTimingMetric
|
||||
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.uiautomator.UiDevice
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StartupMacrobenchmark {
|
||||
@get:Rule
|
||||
val benchmarkRule = MacrobenchmarkRule()
|
||||
|
||||
private val packageName = "ai.openclaw.android"
|
||||
|
||||
@Test
|
||||
fun coldStartup() {
|
||||
runBenchmarkOrSkip {
|
||||
benchmarkRule.measureRepeated(
|
||||
packageName = packageName,
|
||||
metrics = listOf(StartupTimingMetric()),
|
||||
startupMode = StartupMode.COLD,
|
||||
compilationMode = CompilationMode.None(),
|
||||
iterations = 10,
|
||||
) {
|
||||
pressHome()
|
||||
startActivityAndWait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupAndScrollFrameTiming() {
|
||||
runBenchmarkOrSkip {
|
||||
benchmarkRule.measureRepeated(
|
||||
packageName = packageName,
|
||||
metrics = listOf(FrameTimingMetric()),
|
||||
startupMode = StartupMode.WARM,
|
||||
compilationMode = CompilationMode.None(),
|
||||
iterations = 10,
|
||||
) {
|
||||
startActivityAndWait()
|
||||
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||
val x = device.displayWidth / 2
|
||||
val yStart = (device.displayHeight * 0.8f).toInt()
|
||||
val yEnd = (device.displayHeight * 0.25f).toInt()
|
||||
repeat(4) {
|
||||
device.swipe(x, yStart, x, yEnd, 24)
|
||||
device.waitForIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runBenchmarkOrSkip(run: () -> Unit) {
|
||||
try {
|
||||
run()
|
||||
} catch (err: IllegalStateException) {
|
||||
val message = err.message.orEmpty()
|
||||
val knownDeviceIssue =
|
||||
message.contains("Unable to confirm activity launch completion") ||
|
||||
message.contains("no renderthread slices", ignoreCase = true)
|
||||
if (knownDeviceIssue) {
|
||||
assumeTrue("Skipping benchmark on this device: $message", false)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("com.android.test") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
|
||||
CLASS_FILTER="ai.openclaw.android.benchmark.StartupMacrobenchmark#coldStartup"
|
||||
BASELINE_JSON=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-startup-benchmark.sh [--baseline <benchmarkData.json>]
|
||||
|
||||
Runs cold-start macrobenchmark only, then prints a compact summary.
|
||||
Also saves a timestamped snapshot JSON under benchmark/results/.
|
||||
If --baseline is omitted, compares against latest previous snapshot when available.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--baseline)
|
||||
BASELINE_JSON="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "jq required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v adb >/dev/null 2>&1; then
|
||||
echo "adb required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
run_log="$(mktemp -t openclaw-android-bench.XXXXXX.log)"
|
||||
trap 'rm -f "$run_log"' EXIT
|
||||
|
||||
cd "$ANDROID_DIR"
|
||||
|
||||
./gradlew :benchmark:connectedDebugAndroidTest \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class="$CLASS_FILTER" \
|
||||
--console=plain \
|
||||
>"$run_log" 2>&1
|
||||
|
||||
latest_json="$(
|
||||
find "$ANDROID_DIR/benchmark/build/outputs/connected_android_test_additional_output/debug/connected" \
|
||||
-name '*benchmarkData.json' -type f \
|
||||
| while IFS= read -r file; do
|
||||
printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
|
||||
done \
|
||||
| sort -nr \
|
||||
| head -n1 \
|
||||
| cut -f2-
|
||||
)"
|
||||
|
||||
if [[ -z "$latest_json" || ! -f "$latest_json" ]]; then
|
||||
echo "benchmarkData.json not found after run." >&2
|
||||
tail -n 120 "$run_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
snapshot_json="$RESULTS_DIR/startup-$timestamp.json"
|
||||
cp "$latest_json" "$snapshot_json"
|
||||
|
||||
median_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$snapshot_json")"
|
||||
min_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.minimum' "$snapshot_json")"
|
||||
max_ms="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.maximum' "$snapshot_json")"
|
||||
cov="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.coefficientOfVariation' "$snapshot_json")"
|
||||
device="$(jq -r '.context.build.model' "$snapshot_json")"
|
||||
sdk="$(jq -r '.context.build.version.sdk' "$snapshot_json")"
|
||||
runs_count="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.runs | length' "$snapshot_json")"
|
||||
|
||||
printf 'startup.cold.median_ms=%.3f min_ms=%.3f max_ms=%.3f cov=%.4f runs=%s device=%s sdk=%s\n' \
|
||||
"$median_ms" "$min_ms" "$max_ms" "$cov" "$runs_count" "$device" "$sdk"
|
||||
echo "snapshot_json=$snapshot_json"
|
||||
|
||||
if [[ -z "$BASELINE_JSON" ]]; then
|
||||
BASELINE_JSON="$(
|
||||
find "$RESULTS_DIR" -name 'startup-*.json' -type f \
|
||||
| while IFS= read -r file; do
|
||||
if [[ "$file" == "$snapshot_json" ]]; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\t%s\n' "$(stat -f '%m' "$file")" "$file"
|
||||
done \
|
||||
| sort -nr \
|
||||
| head -n1 \
|
||||
| cut -f2-
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE_JSON" ]]; then
|
||||
if [[ ! -f "$BASELINE_JSON" ]]; then
|
||||
echo "Baseline file missing: $BASELINE_JSON" >&2
|
||||
exit 1
|
||||
fi
|
||||
base_median="$(jq -r '.benchmarks[] | select(.name=="coldStartup") | .metrics.timeToInitialDisplayMs.median' "$BASELINE_JSON")"
|
||||
delta_ms="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { printf "%.3f", (a-b) }')"
|
||||
delta_pct="$(awk -v a="$median_ms" -v b="$base_median" 'BEGIN { if (b==0) { print "nan" } else { printf "%.2f", ((a-b)/b)*100 } }')"
|
||||
echo "baseline_median_ms=$base_median delta_ms=$delta_ms delta_pct=$delta_pct%"
|
||||
fi
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
PACKAGE="ai.openclaw.android"
|
||||
ACTIVITY=".MainActivity"
|
||||
DURATION_SECONDS="10"
|
||||
OUTPUT_PERF_DATA=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-startup-hotspots.sh [--package <pkg>] [--activity <activity>] [--duration <sec>] [--out <perf.data>]
|
||||
|
||||
Captures startup CPU profile via simpleperf (app_profiler.py), then prints concise hotspot summaries.
|
||||
Default package/activity target OpenClaw Android startup.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--package)
|
||||
PACKAGE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--activity)
|
||||
ACTIVITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--duration)
|
||||
DURATION_SECONDS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
OUTPUT_PERF_DATA="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "uv required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v adb >/dev/null 2>&1; then
|
||||
echo "adb required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$OUTPUT_PERF_DATA" ]]; then
|
||||
OUTPUT_PERF_DATA="/tmp/openclaw-startup-$(date +%Y%m%d-%H%M%S).perf.data"
|
||||
fi
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
simpleperf_dir=""
|
||||
if [[ -n "${ANDROID_NDK_HOME:-}" && -f "${ANDROID_NDK_HOME}/simpleperf/app_profiler.py" ]]; then
|
||||
simpleperf_dir="${ANDROID_NDK_HOME}/simpleperf"
|
||||
elif [[ -n "${ANDROID_NDK_ROOT:-}" && -f "${ANDROID_NDK_ROOT}/simpleperf/app_profiler.py" ]]; then
|
||||
simpleperf_dir="${ANDROID_NDK_ROOT}/simpleperf"
|
||||
else
|
||||
latest_simpleperf="$(ls -d "${HOME}/Library/Android/sdk/ndk/"*/simpleperf 2>/dev/null | sort -V | tail -n1 || true)"
|
||||
if [[ -n "$latest_simpleperf" && -f "$latest_simpleperf/app_profiler.py" ]]; then
|
||||
simpleperf_dir="$latest_simpleperf"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$simpleperf_dir" ]]; then
|
||||
echo "simpleperf not found. Set ANDROID_NDK_HOME or install NDK under ~/Library/Android/sdk/ndk/." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_profiler="$simpleperf_dir/app_profiler.py"
|
||||
report_py="$simpleperf_dir/report.py"
|
||||
ndk_path="$(cd -- "$simpleperf_dir/.." && pwd)"
|
||||
|
||||
tmp_dir="$(mktemp -d -t openclaw-android-hotspots.XXXXXX)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
capture_log="$tmp_dir/capture.log"
|
||||
dso_csv="$tmp_dir/dso.csv"
|
||||
symbols_csv="$tmp_dir/symbols.csv"
|
||||
children_txt="$tmp_dir/children.txt"
|
||||
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:installDebug --console=plain >"$tmp_dir/install.log" 2>&1
|
||||
|
||||
if ! uv run --no-project python3 "$app_profiler" \
|
||||
-p "$PACKAGE" \
|
||||
-a "$ACTIVITY" \
|
||||
-o "$OUTPUT_PERF_DATA" \
|
||||
--ndk_path "$ndk_path" \
|
||||
-r "-e task-clock:u -f 1000 -g --duration $DURATION_SECONDS" \
|
||||
>"$capture_log" 2>&1; then
|
||||
echo "simpleperf capture failed. tail(capture_log):" >&2
|
||||
tail -n 120 "$capture_log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uv run --no-project python3 "$report_py" \
|
||||
-i "$OUTPUT_PERF_DATA" \
|
||||
--sort dso \
|
||||
--csv \
|
||||
--csv-separator "|" \
|
||||
--include-process-name "$PACKAGE" \
|
||||
>"$dso_csv" 2>"$tmp_dir/report-dso.err"
|
||||
|
||||
uv run --no-project python3 "$report_py" \
|
||||
-i "$OUTPUT_PERF_DATA" \
|
||||
--sort dso,symbol \
|
||||
--csv \
|
||||
--csv-separator "|" \
|
||||
--include-process-name "$PACKAGE" \
|
||||
>"$symbols_csv" 2>"$tmp_dir/report-symbols.err"
|
||||
|
||||
uv run --no-project python3 "$report_py" \
|
||||
-i "$OUTPUT_PERF_DATA" \
|
||||
--children \
|
||||
--sort dso,symbol \
|
||||
-n \
|
||||
--percent-limit 0.2 \
|
||||
--include-process-name "$PACKAGE" \
|
||||
>"$children_txt" 2>"$tmp_dir/report-children.err"
|
||||
|
||||
clean_csv() {
|
||||
awk 'BEGIN{print_on=0} /^Overhead\|/{print_on=1} print_on==1{print}' "$1"
|
||||
}
|
||||
|
||||
echo "perf_data=$OUTPUT_PERF_DATA"
|
||||
echo
|
||||
echo "top_dso_self:"
|
||||
clean_csv "$dso_csv" | tail -n +2 | awk -F'|' 'NR<=10 {printf " %s %s\n", $1, $2}'
|
||||
echo
|
||||
echo "top_symbols_self:"
|
||||
clean_csv "$symbols_csv" | tail -n +2 | awk -F'|' 'NR<=20 {printf " %s %s :: %s\n", $1, $2, $3}'
|
||||
echo
|
||||
echo "app_path_clues_children:"
|
||||
rg 'androidx\.compose|MainActivity|NodeRuntime|NodeForegroundService|SecurePrefs|WebView|libwebviewchromium' "$children_txt" | awk 'NR<=20 {print}' || true
|
||||
@@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "OpenClawNodeAndroid"
|
||||
include(":app")
|
||||
include(":benchmark")
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.23</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260223</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.25</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260225</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.25</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260225</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.23</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260223</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.23</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260227</string>
|
||||
<string>20260223</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.23"
|
||||
CFBundleVersion: "20260223"
|
||||
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.23"
|
||||
CFBundleVersion: "20260223"
|
||||
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.23"
|
||||
CFBundleVersion: "20260223"
|
||||
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.23"
|
||||
CFBundleVersion: "20260223"
|
||||
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.23"
|
||||
CFBundleVersion: "20260223"
|
||||
|
||||
234
apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift
Normal file
234
apps/macos/Sources/OpenClaw/AnthropicAuthControls.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AnthropicAuthControls: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
|
||||
@State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
||||
@State private var pkce: AnthropicOAuth.PKCE?
|
||||
@State private var code: String = ""
|
||||
@State private var busy = false
|
||||
@State private var statusText: String?
|
||||
@State private var autoDetectClipboard = true
|
||||
@State private var autoConnectClipboard = true
|
||||
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
|
||||
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.connectionMode != .local {
|
||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(self.oauthStatus.shortDescription)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(OpenClawOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startOAuth()
|
||||
} label: {
|
||||
if self.busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connectionMode != .local || self.busy)
|
||||
|
||||
if self.pkce != nil {
|
||||
Button("Cancel") {
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy)
|
||||
}
|
||||
}
|
||||
|
||||
if self.pkce != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste `code#state`")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("code#state", text: self.$code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.busy)
|
||||
|
||||
Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.busy)
|
||||
|
||||
Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.busy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy || self.connectionMode != .local || self.code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.refresh()
|
||||
}
|
||||
.onReceive(Self.clipboardPoll) { _ in
|
||||
self.pollClipboardIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
||||
if imported != nil {
|
||||
self.statusText = "Imported existing OAuth credentials."
|
||||
}
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.pkce = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishOAuth() async {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
guard let pkce = self.pkce else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
|
||||
self.statusText = "OAuth failed: missing or invalid code/state."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. OpenClaw can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func pollClipboardIfNeeded() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard self.pkce != nil else { return }
|
||||
guard !self.busy else { return }
|
||||
guard self.autoDetectClipboard else { return }
|
||||
|
||||
let pb = NSPasteboard.general
|
||||
let changeCount = pb.changeCount
|
||||
guard changeCount != self.lastPasteboardChangeCount else { return }
|
||||
self.lastPasteboardChangeCount = changeCount
|
||||
|
||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
||||
guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
|
||||
|
||||
let next = "\(parsed.code)#\(parsed.state)"
|
||||
if self.code != next {
|
||||
self.code = next
|
||||
self.statusText = "Detected `code#state` from clipboard."
|
||||
}
|
||||
|
||||
guard self.autoConnectClipboard else { return }
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension AnthropicAuthControls {
|
||||
init(
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus,
|
||||
pkce: AnthropicOAuth.PKCE? = nil,
|
||||
code: String = "",
|
||||
busy: Bool = false,
|
||||
statusText: String? = nil,
|
||||
autoDetectClipboard: Bool = true,
|
||||
autoConnectClipboard: Bool = true)
|
||||
{
|
||||
self.connectionMode = connectionMode
|
||||
self._oauthStatus = State(initialValue: oauthStatus)
|
||||
self._pkce = State(initialValue: pkce)
|
||||
self._code = State(initialValue: code)
|
||||
self._busy = State(initialValue: busy)
|
||||
self._statusText = State(initialValue: statusText)
|
||||
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
||||
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
||||
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
383
apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
Normal file
383
apps/macos/Sources/OpenClaw/AnthropicOAuth.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
struct AnthropicOAuthCredentials: Codable {
|
||||
let type: String
|
||||
let refresh: String
|
||||
let access: String
|
||||
let expires: Int64
|
||||
}
|
||||
|
||||
enum AnthropicAuthMode: Equatable {
|
||||
case oauthFile
|
||||
case oauthEnv
|
||||
case apiKeyEnv
|
||||
case missing
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (OpenClaw token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
switch self {
|
||||
case .missing: false
|
||||
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore
|
||||
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return .oauthEnv
|
||||
}
|
||||
|
||||
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty
|
||||
{
|
||||
return .apiKeyEnv
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicOAuth {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth")
|
||||
|
||||
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
|
||||
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
|
||||
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||
private static let scopes = "org:create_api_key user:profile user:inference"
|
||||
|
||||
struct PKCE {
|
||||
let verifier: String
|
||||
let challenge: String
|
||||
}
|
||||
|
||||
static func generatePKCE() throws -> PKCE {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
let verifier = Data(bytes).base64URLEncodedString()
|
||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||
let challenge = Data(hash).base64URLEncodedString()
|
||||
return PKCE(verifier: verifier, challenge: challenge)
|
||||
}
|
||||
|
||||
static func buildAuthorizeURL(pkce: PKCE) -> URL {
|
||||
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "code", value: "true"),
|
||||
URLQueryItem(name: "client_id", value: self.clientId),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
|
||||
URLQueryItem(name: "scope", value: self.scopes),
|
||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
// Match legacy flow: state is the verifier.
|
||||
URLQueryItem(name: "state", value: pkce.verifier),
|
||||
]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func exchangeCode(
|
||||
code: String,
|
||||
state: String,
|
||||
verifier: String) async throws -> AnthropicOAuthCredentials
|
||||
{
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.clientId,
|
||||
"code": code,
|
||||
"state": state,
|
||||
"redirect_uri": self.redirectURI,
|
||||
"code_verifier": verifier,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = decoded?["refresh_token"] as? String
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let refresh, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
|
||||
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.clientId,
|
||||
"refresh_token": refreshToken,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR"
|
||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
case unreadableFile
|
||||
case invalidJSON
|
||||
case missingProviderEntry
|
||||
case missingTokens
|
||||
case connected(expiresAtMs: Int64?)
|
||||
|
||||
var isConnected: Bool {
|
||||
if case .connected = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
case .missingFile: "OpenClaw OAuth token file not found"
|
||||
case .unreadableFile: "OpenClaw OAuth token file not readable"
|
||||
case .invalidJSON: "OpenClaw OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "OpenClaw OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
return home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||
}
|
||||
|
||||
static func legacyOAuthURLs() -> [URL] {
|
||||
var urls: [URL] = []
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
||||
}
|
||||
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
|
||||
|
||||
var seen = Set<String>()
|
||||
return urls.filter { url in
|
||||
let path = url.standardizedFileURL.path
|
||||
if seen.contains(path) { return false }
|
||||
seen.insert(path)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||
let dest = self.oauthURL()
|
||||
guard !FileManager().fileExists(atPath: dest.path) else { return nil }
|
||||
|
||||
for url in self.legacyOAuthURLs() {
|
||||
guard FileManager().fileExists(atPath: url.path) else { continue }
|
||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||
guard let storage = self.loadStorage(at: url) else { continue }
|
||||
do {
|
||||
try self.saveStorage(storage)
|
||||
return url
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||
}
|
||||
|
||||
static func hasAnthropicOAuth() -> Bool {
|
||||
self.anthropicOAuthStatus().isConnected
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
|
||||
|
||||
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
||||
guard let storage = json as? [String: Any] else { return .invalidJSON }
|
||||
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
|
||||
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
|
||||
|
||||
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
|
||||
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
|
||||
|
||||
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
|
||||
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
|
||||
ms
|
||||
} else if let number = expiresAny as? NSNumber {
|
||||
number.int64Value
|
||||
} else if let ms = expiresAny as? Double {
|
||||
Int64(ms)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return .connected(expiresAtMs: expiresAtMs)
|
||||
}
|
||||
|
||||
static func loadAnthropicOAuthRefreshToken() -> String? {
|
||||
let url = self.oauthURL()
|
||||
guard let storage = self.loadStorage(at: url) else { return nil }
|
||||
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
|
||||
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String { return value }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadStorage(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
return json as? [String: Any]
|
||||
}
|
||||
|
||||
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||
let url = self.oauthURL()
|
||||
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
|
||||
|
||||
var updated = existing
|
||||
updated[self.providerKey] = [
|
||||
"type": creds.type,
|
||||
"refresh": creds.refresh,
|
||||
"access": creds.access,
|
||||
"expires": creds.expires,
|
||||
]
|
||||
|
||||
try self.saveStorage(updated)
|
||||
}
|
||||
|
||||
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||
let dir = self.oauthDir()
|
||||
try FileManager().createDirectory(
|
||||
at: dir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
|
||||
let url = self.oauthURL()
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: storage,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
fileprivate func base64URLEncodedString() -> String {
|
||||
self.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
59
apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift
Normal file
59
apps/macos/Sources/OpenClaw/AnthropicOAuthCodeState.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
enum AnthropicOAuthCodeState {
|
||||
struct Parsed: Equatable {
|
||||
let code: String
|
||||
let state: String
|
||||
}
|
||||
|
||||
/// Extracts a `code#state` payload from arbitrary text.
|
||||
///
|
||||
/// Supports:
|
||||
/// - raw `code#state`
|
||||
/// - OAuth callback URLs containing `code=` and `state=` query params
|
||||
/// - surrounding text/backticks from instructions pages
|
||||
static func extract(from raw: String) -> String? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
|
||||
if text.isEmpty { return nil }
|
||||
|
||||
if let fromURL = self.extractFromURL(text) { return fromURL }
|
||||
if let fromToken = self.extractFromToken(text) { return fromToken }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parse(from raw: String) -> Parsed? {
|
||||
guard let extracted = self.extract(from: raw) else { return nil }
|
||||
let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
|
||||
let code = parts.first ?? ""
|
||||
let state = parts.count > 1 ? parts[1] : ""
|
||||
guard !code.isEmpty, !state.isEmpty else { return nil }
|
||||
return Parsed(code: code, state: state)
|
||||
}
|
||||
|
||||
private static func extractFromURL(_ text: String) -> String? {
|
||||
// Users might copy the callback URL from the browser address bar.
|
||||
guard let components = URLComponents(string: text),
|
||||
let items = components.queryItems,
|
||||
let code = items.first(where: { $0.name == "code" })?.value,
|
||||
let state = items.first(where: { $0.name == "state" })?.value,
|
||||
!code.isEmpty, !state.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return "\(code)#\(state)"
|
||||
}
|
||||
|
||||
private static func extractFromToken(_ text: String) -> String? {
|
||||
// Base64url-ish tokens; keep this fairly strict to avoid false positives.
|
||||
let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = re.firstMatch(in: text, range: range),
|
||||
match.numberOfRanges == 3,
|
||||
let full = Range(match.range(at: 0), in: text)
|
||||
else { return nil }
|
||||
|
||||
return String(text[full])
|
||||
}
|
||||
}
|
||||
@@ -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_"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawDiscovery
|
||||
@@ -68,6 +69,22 @@ struct OnboardingView: View {
|
||||
@State var workspacePath: String = ""
|
||||
@State var workspaceStatus: String?
|
||||
@State var workspaceApplying = false
|
||||
@State var anthropicAuthPKCE: AnthropicOAuth.PKCE?
|
||||
@State var anthropicAuthCode: String = ""
|
||||
@State var anthropicAuthStatus: String?
|
||||
@State var anthropicAuthBusy = false
|
||||
@State var anthropicAuthConnected = false
|
||||
@State var anthropicAuthVerifying = false
|
||||
@State var anthropicAuthVerified = false
|
||||
@State var anthropicAuthVerificationAttempted = false
|
||||
@State var anthropicAuthVerificationFailed = false
|
||||
@State var anthropicAuthVerifiedAt: Date?
|
||||
@State var anthropicAuthDetectedStatus: OpenClawOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||
@State var anthropicAuthAutoDetectClipboard = true
|
||||
@State var anthropicAuthAutoConnectClipboard = true
|
||||
@State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
@State var monitoringAuth = false
|
||||
@State var authMonitorTask: Task<Void, Never>?
|
||||
@State var needsBootstrap = false
|
||||
@State var didAutoKickoff = false
|
||||
@State var showAdvancedConnection = false
|
||||
@@ -87,9 +104,19 @@ struct OnboardingView: View {
|
||||
let pageWidth: CGFloat = Self.windowWidth
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let anthropicAuthPageIndex = 2
|
||||
let wizardPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
let permissionsPageIndex = 5
|
||||
static func pageOrder(
|
||||
for mode: AppState.ConnectionMode,
|
||||
|
||||
@@ -78,4 +78,70 @@ extension OnboardingView {
|
||||
self.copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
|
||||
}
|
||||
|
||||
func startAnthropicOAuth() {
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
self.anthropicAuthBusy = true
|
||||
defer { self.anthropicAuthBusy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.anthropicAuthPKCE = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func finishAnthropicOAuth() async {
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
guard let pkce = self.anthropicAuthPKCE else { return }
|
||||
self.anthropicAuthBusy = true
|
||||
defer { self.anthropicAuthBusy = false }
|
||||
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
|
||||
self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func pollAnthropicClipboardIfNeeded() {
|
||||
guard self.currentPage == self.anthropicAuthPageIndex else { return }
|
||||
guard self.anthropicAuthPKCE != nil else { return }
|
||||
guard !self.anthropicAuthBusy else { return }
|
||||
guard self.anthropicAuthAutoDetectClipboard else { return }
|
||||
|
||||
let pb = NSPasteboard.general
|
||||
let changeCount = pb.changeCount
|
||||
guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
|
||||
self.anthropicAuthLastPasteboardChangeCount = changeCount
|
||||
|
||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
||||
guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
|
||||
|
||||
let next = "\(parsed.code)#\(parsed.state)"
|
||||
if self.anthropicAuthCode != next {
|
||||
self.anthropicAuthCode = next
|
||||
self.anthropicAuthStatus = "Detected `code#state` from clipboard."
|
||||
}
|
||||
|
||||
guard self.anthropicAuthAutoConnectClipboard else { return }
|
||||
Task { await self.finishAnthropicOAuth() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ extension OnboardingView {
|
||||
.onDisappear {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
self.stopAuthMonitoring()
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
}
|
||||
.task {
|
||||
@@ -60,6 +61,7 @@ extension OnboardingView {
|
||||
self.refreshCLIStatus()
|
||||
await self.loadWorkspaceDefaults()
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.refreshBootstrapStatus()
|
||||
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ extension OnboardingView {
|
||||
func updateMonitoring(for pageIndex: Int) {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.updateAuthMonitoring(for: pageIndex)
|
||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||
}
|
||||
|
||||
@@ -62,6 +63,33 @@ extension OnboardingView {
|
||||
self.gatewayDiscovery.stop()
|
||||
}
|
||||
|
||||
func updateAuthMonitoring(for pageIndex: Int) {
|
||||
let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
|
||||
if shouldMonitor, !self.monitoringAuth {
|
||||
self.monitoringAuth = true
|
||||
self.startAuthMonitoring()
|
||||
} else if !shouldMonitor, self.monitoringAuth {
|
||||
self.stopAuthMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
func startAuthMonitoring() {
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.authMonitorTask?.cancel()
|
||||
self.authMonitorTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await MainActor.run { self.refreshAnthropicOAuthStatus() }
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAuthMonitoring() {
|
||||
self.monitoringAuth = false
|
||||
self.authMonitorTask?.cancel()
|
||||
self.authMonitorTask = nil
|
||||
}
|
||||
|
||||
func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
self.installingCLI = true
|
||||
@@ -97,4 +125,54 @@ extension OnboardingView {
|
||||
expected: expected)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAnthropicOAuthStatus() {
|
||||
_ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
let previous = self.anthropicAuthDetectedStatus
|
||||
let status = OpenClawOAuthStore.anthropicOAuthStatus()
|
||||
self.anthropicAuthDetectedStatus = status
|
||||
self.anthropicAuthConnected = status.isConnected
|
||||
|
||||
if previous != status {
|
||||
self.anthropicAuthVerified = false
|
||||
self.anthropicAuthVerificationAttempted = false
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
self.anthropicAuthVerifiedAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
|
||||
guard self.state.connectionMode == .local else { return }
|
||||
guard self.anthropicAuthDetectedStatus.isConnected else { return }
|
||||
if self.anthropicAuthVerified, !force { return }
|
||||
if self.anthropicAuthVerifying { return }
|
||||
if self.anthropicAuthVerificationAttempted, !force { return }
|
||||
|
||||
self.anthropicAuthVerificationAttempted = true
|
||||
self.anthropicAuthVerifying = true
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
defer { self.anthropicAuthVerifying = false }
|
||||
|
||||
guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
|
||||
self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
|
||||
self.anthropicAuthVerificationFailed = true
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
|
||||
try OpenClawOAuthStore.saveAnthropicOAuth(updated)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthVerified = true
|
||||
self.anthropicAuthVerifiedAt = Date()
|
||||
self.anthropicAuthVerificationFailed = false
|
||||
self.anthropicAuthStatus = "OAuth detected and verified."
|
||||
} catch {
|
||||
self.anthropicAuthVerified = false
|
||||
self.anthropicAuthVerifiedAt = nil
|
||||
self.anthropicAuthVerificationFailed = true
|
||||
self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ extension OnboardingView {
|
||||
self.welcomePage()
|
||||
case 1:
|
||||
self.connectionPage()
|
||||
case 2:
|
||||
self.anthropicAuthPage()
|
||||
case 3:
|
||||
self.wizardPage()
|
||||
case 5:
|
||||
@@ -338,6 +340,170 @@ extension OnboardingView {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func anthropicAuthPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Connect Claude")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Give your model the token it needs!")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 12, padding: 16) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(
|
||||
self.anthropicAuthConnected
|
||||
? (self.anthropicAuthVerified
|
||||
? "Claude connected (OAuth) — verified"
|
||||
: "Claude connected (OAuth)")
|
||||
: "Not connected yet")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if self.anthropicAuthConnected, self.anthropicAuthVerifying {
|
||||
Text("Verifying OAuth…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if !self.anthropicAuthConnected {
|
||||
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
|
||||
Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Text(
|
||||
"This lets OpenClaw use Claude immediately. Credentials are stored at " +
|
||||
"`~/.openclaw/credentials/oauth.json` (owner-only).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(OpenClawOAuthStore.oauthURL().path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Refresh") {
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if !self.anthropicAuthVerified {
|
||||
if self.anthropicAuthConnected {
|
||||
Button("Verify") {
|
||||
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||
|
||||
if self.anthropicAuthVerificationFailed {
|
||||
Button("Re-auth (OAuth)") {
|
||||
self.startAnthropicOAuth()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
self.startAnthropicOAuth()
|
||||
} label: {
|
||||
if self.anthropicAuthBusy {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Open Claude sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste the `code#state` value")
|
||||
.font(.headline)
|
||||
TextField("code#state", text: self.$anthropicAuthCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
|
||||
Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.anthropicAuthBusy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishAnthropicOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(
|
||||
self.anthropicAuthBusy ||
|
||||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.onReceive(Self.clipboardPoll) { _ in
|
||||
self.pollAnthropicClipboardIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
self.onboardingCard(spacing: 8, padding: 12) {
|
||||
Text("API key (advanced)")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
||||
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.shadow(color: .clear, radius: 0)
|
||||
.background(Color.clear)
|
||||
|
||||
if let status = self.anthropicAuthStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await self.verifyAnthropicOAuthIfNeeded() }
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
|
||||
@@ -37,9 +37,18 @@ extension OnboardingView {
|
||||
view.cliStatus = "Installed"
|
||||
view.workspacePath = "/tmp/openclaw"
|
||||
view.workspaceStatus = "Saved workspace"
|
||||
view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
||||
view.anthropicAuthCode = "code#state"
|
||||
view.anthropicAuthStatus = "Connected"
|
||||
view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000)
|
||||
view.anthropicAuthConnected = true
|
||||
view.anthropicAuthAutoDetectClipboard = false
|
||||
view.anthropicAuthAutoConnectClipboard = false
|
||||
|
||||
view.state.connectionMode = .local
|
||||
_ = view.welcomePage()
|
||||
_ = view.connectionPage()
|
||||
_ = view.anthropicAuthPage()
|
||||
_ = view.wizardPage()
|
||||
_ = view.permissionsPage()
|
||||
_ = view.cliPage()
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.27</string>
|
||||
<string>2026.2.25</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602270</string>
|
||||
<string>202602250</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)
|
||||
{
|
||||
|
||||
@@ -408,7 +408,6 @@ public struct SendParams: Codable, Sendable {
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
public let agentid: String?
|
||||
public let threadid: String?
|
||||
public let sessionkey: String?
|
||||
public let idempotencykey: String
|
||||
@@ -421,7 +420,6 @@ public struct SendParams: Codable, Sendable {
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
agentid: String?,
|
||||
threadid: String?,
|
||||
sessionkey: String?,
|
||||
idempotencykey: String)
|
||||
@@ -433,7 +431,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.agentid = agentid
|
||||
self.threadid = threadid
|
||||
self.sessionkey = sessionkey
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -447,7 +444,6 @@ public struct SendParams: Codable, Sendable {
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case agentid = "agentId"
|
||||
case threadid = "threadId"
|
||||
case sessionkey = "sessionKey"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
@@ -2809,9 +2805,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
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,19 +2813,12 @@ 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?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2841,18 +2827,11 @@ 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 +2840,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
|
||||
}
|
||||
@@ -2872,9 +2847,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2883,10 +2855,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 +2968,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 +2984,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -3033,7 +2999,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 +3016,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
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct AnthropicAuthControlsSmokeTests {
|
||||
@Test func anthropicAuthControlsBuildsBodyLocal() {
|
||||
let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
||||
let view = AnthropicAuthControls(
|
||||
connectionMode: .local,
|
||||
oauthStatus: .connected(expiresAtMs: 1_700_000_000_000),
|
||||
pkce: pkce,
|
||||
code: "code#state",
|
||||
statusText: "Detected code",
|
||||
autoDetectClipboard: false,
|
||||
autoConnectClipboard: false)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func anthropicAuthControlsBuildsBodyRemote() {
|
||||
let view = AnthropicAuthControls(
|
||||
connectionMode: .remote,
|
||||
oauthStatus: .missingFile,
|
||||
pkce: nil,
|
||||
code: "",
|
||||
statusText: nil)
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct AnthropicAuthResolverTests {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
||||
let payload = [
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
"expires": 1_234_567_890,
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: oauthFile, options: [.atomic])
|
||||
|
||||
let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile)
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-ignored",
|
||||
], oauthStatus: status)
|
||||
#expect(mode == .oauthFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsOAuthEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_OAUTH_TOKEN": "token",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .oauthEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsAPIKeyEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-key",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .apiKeyEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingWhenNothingConfigured() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
|
||||
#expect(mode == .missing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct AnthropicOAuthCodeStateTests {
|
||||
@Test
|
||||
func parsesRawToken() {
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func parsesBacktickedToken() {
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func parsesCallbackURL() {
|
||||
let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func extractsFromSurroundingText() {
|
||||
let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
|
||||
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite
|
||||
struct OpenClawOAuthStoreTests {
|
||||
@Test
|
||||
func returnsMissingWhenFileAbsent() {
|
||||
let url = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)")
|
||||
.appendingPathComponent("oauth.json")
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usesEnvOverrideForOpenClawOAuthDir() throws {
|
||||
let key = "OPENCLAW_OAUTH_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
#expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptsPiFormatTokens() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
"expires": 1_234_567_890,
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptsTokenKeyVariants() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh_token": "r1",
|
||||
"access_token": "a1",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingProviderEntry() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"other": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingTokens() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "",
|
||||
"access": "a1",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
||||
}
|
||||
|
||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let url = dir.appendingPathComponent("oauth.json")
|
||||
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -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] = [
|
||||
|
||||
@@ -408,7 +408,6 @@ public struct SendParams: Codable, Sendable {
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
public let agentid: String?
|
||||
public let threadid: String?
|
||||
public let sessionkey: String?
|
||||
public let idempotencykey: String
|
||||
@@ -421,7 +420,6 @@ public struct SendParams: Codable, Sendable {
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
agentid: String?,
|
||||
threadid: String?,
|
||||
sessionkey: String?,
|
||||
idempotencykey: String)
|
||||
@@ -433,7 +431,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.agentid = agentid
|
||||
self.threadid = threadid
|
||||
self.sessionkey = sessionkey
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -447,7 +444,6 @@ public struct SendParams: Codable, Sendable {
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case agentid = "agentId"
|
||||
case threadid = "threadId"
|
||||
case sessionkey = "sessionKey"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
@@ -2809,9 +2805,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
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,19 +2813,12 @@ 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?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
commandargv: [String]?,
|
||||
systemrunplanv2: [String: AnyCodable]?,
|
||||
env: [String: AnyCodable]?,
|
||||
cwd: AnyCodable?,
|
||||
nodeid: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
@@ -2841,18 +2827,11 @@ 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 +2840,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
|
||||
}
|
||||
@@ -2872,9 +2847,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case commandargv = "commandArgv"
|
||||
case systemrunplanv2 = "systemRunPlanV2"
|
||||
case env
|
||||
case cwd
|
||||
case nodeid = "nodeId"
|
||||
case host
|
||||
@@ -2883,10 +2855,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 +2968,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 +2984,6 @@ public struct DevicePairRequestedEvent: Codable, Sendable {
|
||||
publickey: String,
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
devicefamily: String?,
|
||||
clientid: String?,
|
||||
clientmode: String?,
|
||||
role: String?,
|
||||
@@ -3033,7 +2999,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 +3016,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' })
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Changelog Fragments
|
||||
|
||||
Use this directory when a PR should not edit `CHANGELOG.md` directly.
|
||||
|
||||
- One fragment file per PR.
|
||||
- File name recommendation: `pr-<number>.md`.
|
||||
- Include at least one line with both `#<pr>` and `thanks @<contributor>`.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice)
|
||||
```
|
||||
@@ -20,78 +20,6 @@ require_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
read_config_gateway_token() {
|
||||
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$config_path" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except Exception:
|
||||
raise SystemExit(0)
|
||||
|
||||
gateway = cfg.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
raise SystemExit(0)
|
||||
auth = gateway.get("auth")
|
||||
if not isinstance(auth, dict):
|
||||
raise SystemExit(0)
|
||||
token = auth.get("token")
|
||||
if isinstance(token, str):
|
||||
token = token.strip()
|
||||
if token:
|
||||
print(token)
|
||||
PY
|
||||
return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node - "$config_path" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const configPath = process.argv[2];
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const token = cfg?.gateway?.auth?.token;
|
||||
if (typeof token === "string" && token.trim().length > 0) {
|
||||
process.stdout.write(token.trim());
|
||||
}
|
||||
} catch {
|
||||
// Keep docker-setup resilient when config parsing fails.
|
||||
}
|
||||
NODE
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_control_ui_allowed_origins() {
|
||||
if [[ "${OPENCLAW_GATEWAY_BIND}" == "loopback" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local allowed_origin_json
|
||||
local current_allowed_origins
|
||||
allowed_origin_json="$(printf '["http://127.0.0.1:%s"]' "$OPENCLAW_GATEWAY_PORT")"
|
||||
current_allowed_origins="$(
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config get gateway.controlUi.allowedOrigins 2>/dev/null || true
|
||||
)"
|
||||
current_allowed_origins="${current_allowed_origins//$'\r'/}"
|
||||
|
||||
if [[ -n "$current_allowed_origins" && "$current_allowed_origins" != "null" && "$current_allowed_origins" != "[]" ]]; then
|
||||
echo "Control UI allowlist already configured; leaving gateway.controlUi.allowedOrigins unchanged."
|
||||
return 0
|
||||
fi
|
||||
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
|
||||
config set gateway.controlUi.allowedOrigins "$allowed_origin_json" --strict-json >/dev/null
|
||||
echo "Set gateway.controlUi.allowedOrigins to $allowed_origin_json for non-loopback bind."
|
||||
}
|
||||
|
||||
contains_disallowed_chars() {
|
||||
local value="$1"
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
@@ -169,11 +97,7 @@ export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
|
||||
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
|
||||
|
||||
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
|
||||
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
|
||||
if [[ -n "$EXISTING_CONFIG_TOKEN" ]]; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$EXISTING_CONFIG_TOKEN"
|
||||
echo "Reusing gateway token from $OPENCLAW_CONFIG_DIR/openclaw.json"
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
OPENCLAW_GATEWAY_TOKEN="$(openssl rand -hex 32)"
|
||||
else
|
||||
OPENCLAW_GATEWAY_TOKEN="$(python3 - <<'PY'
|
||||
@@ -331,10 +255,12 @@ if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
||||
-f "$ROOT_DIR/Dockerfile" \
|
||||
"$ROOT_DIR"
|
||||
else
|
||||
if [[ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]]; then
|
||||
fail "OPENCLAW_DOCKER_APT_PACKAGES is build-only and cannot be used when OPENCLAW_IMAGE is not openclaw:local."
|
||||
fi
|
||||
echo "==> Pulling Docker image: $IMAGE_NAME"
|
||||
if ! docker pull "$IMAGE_NAME"; then
|
||||
echo "ERROR: Failed to pull image $IMAGE_NAME. Please check the image name and your access permissions." >&2
|
||||
exit 1
|
||||
fail "Failed to pull image $IMAGE_NAME. Check the image name and access permissions."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -349,10 +275,6 @@ echo " - Install Gateway daemon: No"
|
||||
echo ""
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli onboard --no-install-daemon
|
||||
|
||||
echo ""
|
||||
echo "==> Control UI origin allowlist"
|
||||
ensure_control_ui_allowed_origins
|
||||
|
||||
echo ""
|
||||
echo "==> Provider setup (optional)"
|
||||
echo "WhatsApp (QR):"
|
||||
|
||||
@@ -376,12 +376,6 @@ Example:
|
||||
|
||||
If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode).
|
||||
|
||||
Multi-account precedence:
|
||||
|
||||
- `channels.discord.accounts.default.allowFrom` applies only to the `default` account.
|
||||
- Named accounts inherit `channels.discord.allowFrom` when their own `allowFrom` is unset.
|
||||
- Named accounts do not inherit `channels.discord.accounts.default.allowFrom`.
|
||||
|
||||
DM target format for delivery:
|
||||
|
||||
- `user:<id>`
|
||||
@@ -642,8 +636,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 +645,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 +665,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user