mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:41:16 +08:00
Compare commits
1 Commits
codex/8870
...
josh/trans
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ea501f38 |
@@ -22,8 +22,6 @@ Use when:
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
|
||||
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
|
||||
@@ -307,7 +307,8 @@ Live-provider debug template for direct AWS/Hetzner leases:
|
||||
|
||||
```sh
|
||||
mkdir -p .crabbox/logs
|
||||
pnpm crabbox:run -- --provider aws \
|
||||
CRABBOX_ENV_ALLOW=OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
pnpm crabbox:run -- --provider aws \
|
||||
--preflight \
|
||||
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
--timing-json \
|
||||
@@ -319,10 +320,8 @@ pnpm crabbox:run -- --provider aws \
|
||||
```
|
||||
|
||||
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
|
||||
`--sync-only` to delegated providers. Also do not pass `--script*`,
|
||||
`--fresh-pr`, `--full-resync`, or `--env-helper` there. Crabbox rejects these
|
||||
because the provider owns sync or command transport. `--keep-on-failure` is OK
|
||||
for delegated one-shots when you need to inspect a failed lease.
|
||||
`--sync-only` to delegated providers. Crabbox rejects them because the provider
|
||||
owns sync or command transport.
|
||||
|
||||
## Efficient Bug E2E Verification
|
||||
|
||||
|
||||
202
.agents/skills/kysely-database-access/SKILL.md
Normal file
202
.agents/skills/kysely-database-access/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
name: kysely-database-access
|
||||
description: Use when adding, reviewing, or refactoring OpenClaw Kysely database access, native node:sqlite stores, generated DB types, SQLite schemas, migrations, raw SQL, transactions, or database access best practices.
|
||||
---
|
||||
|
||||
# Kysely Database Access
|
||||
|
||||
Use this skill for OpenClaw database code that touches Kysely, `node:sqlite`,
|
||||
generated DB types, SQLite schemas, migrations, or store/query design.
|
||||
|
||||
## Read First
|
||||
|
||||
- `docs/concepts/kysely.md` for the repo's Kysely rules and examples.
|
||||
- The owning subtree `AGENTS.md`, if present.
|
||||
- Relevant local Kysely source/types under `node_modules/kysely/dist/esm/...`
|
||||
before assuming dialect behavior, result types, transactions, plugins, or raw
|
||||
SQL semantics.
|
||||
- For codegen behavior, inspect `scripts/generate-kysely-types.mjs` and
|
||||
`kysely-codegen --help` from the repo package manager.
|
||||
|
||||
## Official Docs Cross-Check
|
||||
|
||||
When the behavior matters, verify against current Kysely docs/source before
|
||||
patching:
|
||||
|
||||
- Generating types: production apps should keep schema types aligned with the
|
||||
database through code generation.
|
||||
- Data types: TypeScript types do not affect runtime values; the driver decides
|
||||
runtime values, and Kysely returns what the driver returns unless a plugin
|
||||
transforms results.
|
||||
- Raw SQL: the `sql` tag can execute full raw SQL and embed snippets into
|
||||
builders. Prefer typed builders/helpers when they express the same thing.
|
||||
- Reusable helpers: take `Expression<T>` or an `ExpressionBuilder` when wrapping
|
||||
SQL expressions; alias helper expressions explicitly in `select`. Extract a
|
||||
helper only when it quarantines raw SQL, removes meaningful duplication, or
|
||||
preserves a tricky inferred type.
|
||||
- Split build/execute only at deliberate boundaries. Compiled-query execution
|
||||
is useful for native sync adapters, but keep plugin/result-transform behavior
|
||||
in mind.
|
||||
- Migrations: Kysely migration files run without a schema type. In OpenClaw,
|
||||
prefer the committed SQL-source-of-truth path unless a new owner explicitly
|
||||
needs Kysely-managed migrations.
|
||||
- Plugins: plugins can transform queries and results. Any sync shortcut that
|
||||
bypasses Kysely's async executor needs a documented invariant or tests.
|
||||
|
||||
## Default Workflow
|
||||
|
||||
1. Identify the owner boundary:
|
||||
- Core state DB: `src/state/*`
|
||||
- Per-agent DB: `src/state/openclaw-agent-*`
|
||||
- Feature store: owning `*.sqlite.ts` module
|
||||
- Plugin-owned state: plugin/module owner, not generic core
|
||||
2. Inspect the schema source first:
|
||||
- `*.sql` is the source of truth when generated schema/types exist.
|
||||
- Generated `*.generated.*` files are outputs, not hand-edit targets.
|
||||
3. Prefer Kysely builders for normal CRUD:
|
||||
- `selectFrom`, `insertInto`, `updateTable`, `deleteFrom`
|
||||
- `executeTakeFirst`, `executeTakeFirstOrThrow`, `execute`
|
||||
- `eb.fn.countAll`, `eb.fn.count`, `eb.fn.coalesce` for common functions
|
||||
- Keep compile-time Kysely reference literals such as `"host"` and
|
||||
`"flow_id as flowId"` when they are clearer than constants; they are
|
||||
type-checked by Kysely.
|
||||
- Let Kysely infer selected row shapes. Do not pass broad row generics to
|
||||
sync helpers for normal builder queries.
|
||||
- Treat `executeSqliteQuerySync<Row>(db, builder)` and
|
||||
`executeSqliteQueryTakeFirstSync<Row>(db, builder)` as a smell: the generic
|
||||
can lie about selected columns. Use no generic for builders; use an exact
|
||||
raw boundary helper for raw SQL.
|
||||
- For finite public query presets, use a preset-to-row type map plus a union
|
||||
boundary type instead of `Record<string, ...>`.
|
||||
- After touching Kysely/native SQLite code, run `pnpm lint:kysely`. The AST
|
||||
guard rejects raw identifier helpers, unreviewed typed `sql<T>` snippets,
|
||||
`db.dynamic`, explicit sync-helper row generics for builders, and new raw
|
||||
`node:sqlite` runtime access outside owner allowlists. It also rejects
|
||||
persisted enum-like casts in SQLite stores; keep row fields as `string` and
|
||||
parse through closed validators.
|
||||
4. Keep raw SQL deliberate:
|
||||
- Good: pragmas, virtual tables, FTS, SQLite JSON functions, migrations,
|
||||
`sqlite_master`, compact repeated expressions.
|
||||
- Bad: raw `COUNT(*)` or dynamic SQL where Kysely has a typed builder shape.
|
||||
- Use `${value}` parameters; use `sql.ref` / `sql.table` only for validated,
|
||||
closed-set identifiers.
|
||||
- Do not feed unconstrained runtime `string` values into table/column/group/
|
||||
order/identifier positions. Narrow them to local unions or generated table
|
||||
keys first.
|
||||
- Prefer `eb.fn`, `eb.lit`, `eb.ref`, and expression callbacks for scalar
|
||||
SQL such as `count`, `coalesce`, `max`, `exists`, and constant selections.
|
||||
5. Align TypeScript with real driver values:
|
||||
- Kysely does not coerce runtime values.
|
||||
- Native `node:sqlite` returns BLOB columns as `Uint8Array`; convert with
|
||||
`Buffer.from(...)` only at API boundaries that need Buffer helpers.
|
||||
- Keep JSON/text/timestamp parsing at module boundaries.
|
||||
- Keep persisted enum-like strings as `string` in row types, then parse them
|
||||
through closed validator helpers such as `parseTaskStatus(value)`. Do not
|
||||
cast corrupt persisted data into exported unions.
|
||||
6. Decide migration need from shipped state:
|
||||
- Unshipped schema/type cleanup: no SQLite migration.
|
||||
- Shipped canonical schema change: add the appropriate migration or
|
||||
doctor/fix repair path with tests.
|
||||
- Legacy config repair belongs in doctor/fix paths, not startup surprises.
|
||||
|
||||
## Codegen
|
||||
|
||||
For committed SQL-backed generated types:
|
||||
|
||||
```bash
|
||||
pnpm db:kysely:gen
|
||||
pnpm db:kysely:check
|
||||
```
|
||||
|
||||
The repo maps SQLite `blob` to `Uint8Array` through `kysely-codegen`
|
||||
`--type-mapping`. Do not post-process generated files by hand; change the
|
||||
generator or SQL source and regenerate.
|
||||
|
||||
## Native SQLite Guardrails
|
||||
|
||||
- Use `getNodeSqliteKysely(db)` and sync helpers from `src/infra/kysely-sync.ts`
|
||||
for `DatabaseSync` stores.
|
||||
- New direct `db.prepare(...)` / `db.exec(...)` runtime access should be rare.
|
||||
Prefer Kysely or add an explicit `scripts/check-kysely-guardrails.mjs`
|
||||
allowlist entry with a clear owner reason.
|
||||
- If raw SQLite is repeated or cast-heavy, extract a narrow boundary helper
|
||||
such as `assertSqliteIntegrityOk(db, message)` and allowlist that helper
|
||||
instead of each caller.
|
||||
- Keep sync helper result types derived from `CompiledQuery<Row>` / Kysely
|
||||
builders. Explicit helper generics are for raw SQL or external boundaries,
|
||||
not for widening a typed builder result into a generic record.
|
||||
- Keep the native dialect in `src/infra/kysely-node-sqlite.ts` aligned with
|
||||
Kysely's SQLite driver structure: single connection, mutex, SQLite adapter,
|
||||
SQLite query compiler, SQLite introspector.
|
||||
- Use `StatementSync.columns().length` behavior for row-returning statements;
|
||||
do not parse SQL verbs.
|
||||
- Return `insertId` only for changed Kysely insert nodes. Raw insert SQL and
|
||||
ignored inserts must not expose stale `lastInsertRowid`.
|
||||
- Remember that sync execution compiles through Kysely but bypasses async
|
||||
`executeQuery` result plugins/logging. If plugins enter this path, add tests
|
||||
or a documented invariant.
|
||||
|
||||
## Tests
|
||||
|
||||
Pick the smallest proof that covers the touched surface:
|
||||
|
||||
```bash
|
||||
pnpm db:kysely:check
|
||||
pnpm lint:kysely
|
||||
pnpm test src/infra/kysely-node-sqlite.test.ts
|
||||
pnpm test <owning-store>.test.ts
|
||||
pnpm tsgo:core
|
||||
```
|
||||
|
||||
Add or update focused tests for:
|
||||
|
||||
- generated type/runtime mismatches
|
||||
- native dialect metadata (`insertId`, `numAffectedRows`, row-returning SQL)
|
||||
- transactions/savepoints
|
||||
- BLOB and JSON boundary conversions
|
||||
- schema/codegen drift
|
||||
- type inference contracts for sync helpers and public query result maps
|
||||
- negative type contracts with `@ts-expect-error` for important column/preset
|
||||
mistakes
|
||||
- corruption-path tests that mutate SQLite directly and assert the public load
|
||||
or read method rejects invalid persisted strings
|
||||
- public store behavior, not just private SQL shape
|
||||
|
||||
## Helper Extraction
|
||||
|
||||
Good helpers:
|
||||
|
||||
- `readSqliteNumberPragma(db, pragma)` style helpers with a closed union for
|
||||
PRAGMA names.
|
||||
- Raw-expression helpers that accept Kysely expressions/refs instead of raw
|
||||
column strings.
|
||||
- Public query preset maps that preserve exact row types at the API boundary.
|
||||
|
||||
Avoid helpers that:
|
||||
|
||||
- Wrap obvious Kysely literals just to avoid strings.
|
||||
- Take generic `string` table/column/order names.
|
||||
- Return heavily generic query builders that are harder to type than the query
|
||||
they hide.
|
||||
|
||||
## Performance
|
||||
|
||||
- Benchmark prepare/compile overhead before adding statement caches or compiled
|
||||
query caches. Include the real public store method work: SQLite execution,
|
||||
JSON/BLOB conversion, and result mapping.
|
||||
- Keep caches local, close/dispose them with the owning store, and test invalid
|
||||
or stale behavior. Clear builders are the default until numbers prove a hot
|
||||
path.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Do not introduce ORM/repository layers or hidden relation loading.
|
||||
- Do not make root dependencies for plugin-only database needs.
|
||||
- Do not migrate everything to raw SQL or everything to builders for purity.
|
||||
- Do not hand-edit generated DB types.
|
||||
- Do not hide finite query result shapes behind `Record<string, ...>` just to
|
||||
make JSON output convenient; use exact row unions or map at the boundary.
|
||||
- Do not replace every Kysely string literal with constants for aesthetics; fix
|
||||
dynamic identifiers, raw SQL assertions, and public result boundaries instead.
|
||||
- Do not add broad cache layers to hide repeated query/discovery work; carry the
|
||||
known runtime fact earlier when possible.
|
||||
@@ -187,37 +187,11 @@ gh pr view <number> --json additions,deletions,changedFiles \
|
||||
## Read beyond the diff
|
||||
|
||||
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
|
||||
- Before any verdict, read enough code to fill this map: changed surface, runtime entry point, owner boundary, one caller, one callee, sibling implementations sharing the invariant, adjacent tests, current `main` behavior, and shipped/dependency/Codex contracts when relevant.
|
||||
- For large-codebase PRs, sample enough related files to understand the runtime boundary before deciding. Default to more code reading when the change touches agents, gateway, plugins, auth, sessions, process, config, or provider/runtime seams.
|
||||
- Compare the PR against current `origin/main` behavior. Check whether recent main already changed the same surface.
|
||||
- Dependency-backed behavior: MUST read upstream docs/source/types before judging API use, defaults, output shapes, errors, timeouts, memory behavior, or compatibility. Do not assume dependency contracts from memory or PR text.
|
||||
- Judge solution quality, not only correctness. Ask whether the PR is the clean owner-boundary fix or a wart/workaround that should be replaced by a small refactor, moved seam, contract change, or deletion of duplicate logic.
|
||||
- Mention the main files read when the verdict depends on code-path evidence.
|
||||
- If the user challenges the verdict or asks whether the idea is really good, resume code reading first. Do not defend, soften, or reverse the verdict until the missing caller/callee/sibling/dependency path is checked.
|
||||
|
||||
## Best-fix review loop
|
||||
|
||||
Every PR review must explicitly answer: "Is this the best fix, or only a plausible fix?"
|
||||
|
||||
Before verdict:
|
||||
|
||||
1. Reconstruct the bug, feature need, or behavior claim from issue/PR/proof.
|
||||
2. Trace current behavior from entry point to failure or decision point.
|
||||
3. Read touched files, callers, callees, owner modules, adjacent tests, and relevant docs.
|
||||
4. Read sibling surfaces that should share the invariant or could be broken by a one-sided fix.
|
||||
5. Compare against current `origin/main` and shipped behavior when regression/compat matters.
|
||||
6. Inspect upstream dependency/Codex source or docs for dependency-backed behavior.
|
||||
7. Identify at least one alternative fix location or shape, then reject it with evidence.
|
||||
8. If any required path above is uninspected, keep reading or mark `Remaining uncertainty`; do not call the PR best, blocked, proof-sufficient, or merge-ready.
|
||||
|
||||
Review output must include:
|
||||
|
||||
- `Best-fix verdict:` best / acceptable mitigation / wrong layer / too narrow / too broad.
|
||||
- `Alternatives considered:` 1-3 concrete alternatives and why rejected.
|
||||
- `Code read:` compact list of main files/contracts checked.
|
||||
- `Remaining uncertainty:` what was not proven.
|
||||
|
||||
If the best-fix answer is only "maybe", keep reading or state the missing evidence. Do not call proof sufficient until the best-fix judgment is explicit.
|
||||
|
||||
## Enforce the bug-fix evidence bar
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Secret scanning alert handler for OpenClaw maintainers.
|
||||
// Usage: node secret-scanning.mjs <command> [options]
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -39,9 +39,7 @@ function gh(args, { json = true, allowFailure = false } = {}) {
|
||||
stderr: proc.stderr,
|
||||
};
|
||||
}
|
||||
if (!json) {
|
||||
return proc.stdout;
|
||||
}
|
||||
if (!json) return proc.stdout;
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -72,9 +70,7 @@ export function loadBodyRedactionResult(locationType, resultFile) {
|
||||
if (!resultFile) {
|
||||
fail("Body notifications require a redaction result file from redact-body-if-needed");
|
||||
}
|
||||
if (!fs.existsSync(resultFile)) {
|
||||
fail(`File not found: ${resultFile}`);
|
||||
}
|
||||
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
|
||||
|
||||
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
|
||||
if (typeof result.notify_required !== "boolean") {
|
||||
@@ -186,11 +182,10 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
|
||||
failOnGraphQLFailure(gql, `Failed to fetch discussion #${discussionNumber}`);
|
||||
|
||||
const discussion = gql?.data?.repository?.discussion;
|
||||
if (!discussion) {
|
||||
if (!discussion)
|
||||
fail(
|
||||
`Discussion #${discussionNumber} not found — it may have been deleted. The alert cannot be processed via this skill.`,
|
||||
);
|
||||
}
|
||||
|
||||
discussionId = discussion.id;
|
||||
|
||||
@@ -210,18 +205,15 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
|
||||
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
|
||||
);
|
||||
const replies = replyPage?.data?.node?.replies;
|
||||
if (!replies) {
|
||||
if (!replies)
|
||||
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
|
||||
}
|
||||
|
||||
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
|
||||
hasMoreReplies = replies.pageInfo.hasNextPage;
|
||||
replyCursor = replies.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
if (reply) {
|
||||
return { discussionId, comment: reply };
|
||||
}
|
||||
if (reply) return { discussionId, comment: reply };
|
||||
}
|
||||
|
||||
hasNextPage = discussion.comments.pageInfo.hasNextPage;
|
||||
@@ -249,9 +241,7 @@ function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
|
||||
* Fetch alert metadata + locations. Never exposes .secret.
|
||||
*/
|
||||
function cmdFetchAlert(alertNumber) {
|
||||
if (!alertNumber) {
|
||||
fail("Usage: fetch-alert <number>");
|
||||
}
|
||||
if (!alertNumber) fail("Usage: fetch-alert <number>");
|
||||
|
||||
const alert = gh(["api", `repos/${REPO}/secret-scanning/alerts/${alertNumber}?hide_secret=true`]);
|
||||
|
||||
@@ -290,23 +280,17 @@ function cmdFetchAlert(alertNumber) {
|
||||
* Saves full body to a temp file. Prints metadata + file path to stdout.
|
||||
*/
|
||||
function cmdFetchContent(locationJson) {
|
||||
if (!locationJson) {
|
||||
fail("Usage: fetch-content '<location-json>'");
|
||||
}
|
||||
if (!locationJson) fail("Usage: fetch-content '<location-json>'");
|
||||
const location = JSON.parse(locationJson);
|
||||
const type = location.type;
|
||||
const details = location.details;
|
||||
|
||||
if (type === "discussion_comment") {
|
||||
const commentUrl = details.discussion_comment_url;
|
||||
if (!commentUrl) {
|
||||
fail("No discussion_comment_url in location details");
|
||||
}
|
||||
if (!commentUrl) fail("No discussion_comment_url in location details");
|
||||
|
||||
const urlMatch = commentUrl.match(/discussions\/(\d+)#discussioncomment-(\d+)/);
|
||||
if (!urlMatch) {
|
||||
fail(`Cannot parse discussion comment URL: ${commentUrl}`);
|
||||
}
|
||||
if (!urlMatch) fail(`Cannot parse discussion comment URL: ${commentUrl}`);
|
||||
const discussionNumber = urlMatch[1];
|
||||
const discussionCommentDbId = urlMatch[2];
|
||||
|
||||
@@ -314,11 +298,10 @@ function cmdFetchContent(locationJson) {
|
||||
discussionNumber,
|
||||
discussionCommentDbId,
|
||||
);
|
||||
if (!comment) {
|
||||
if (!comment)
|
||||
fail(
|
||||
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
const bodyFile = tmpFile("body.md");
|
||||
fs.writeFileSync(bodyFile, comment.body || "");
|
||||
@@ -351,9 +334,7 @@ function cmdFetchContent(locationJson) {
|
||||
details.issue_comment_url ||
|
||||
details.pull_request_comment_url ||
|
||||
details.pull_request_review_comment_url;
|
||||
if (!commentUrl) {
|
||||
fail(`No comment URL in location details`);
|
||||
}
|
||||
if (!commentUrl) fail(`No comment URL in location details`);
|
||||
|
||||
const comment = gh(["api", commentUrl]);
|
||||
const bodyFile = tmpFile("body.md");
|
||||
@@ -397,9 +378,7 @@ function cmdFetchContent(locationJson) {
|
||||
);
|
||||
} else if (type === "issue_body") {
|
||||
const issueUrl = details.issue_body_url || details.issue_url;
|
||||
if (!issueUrl) {
|
||||
fail("No issue URL in location details");
|
||||
}
|
||||
if (!issueUrl) fail("No issue URL in location details");
|
||||
|
||||
const issue = gh(["api", issueUrl]);
|
||||
const bodyFile = tmpFile("body.md");
|
||||
@@ -435,9 +414,7 @@ function cmdFetchContent(locationJson) {
|
||||
);
|
||||
} else if (type === "pull_request_body") {
|
||||
const prUrl = details.pull_request_body_url || details.pull_request_url;
|
||||
if (!prUrl) {
|
||||
fail("No PR URL in location details");
|
||||
}
|
||||
if (!prUrl) fail("No PR URL in location details");
|
||||
|
||||
const pr = gh(["api", prUrl]);
|
||||
const bodyFile = tmpFile("body.md");
|
||||
@@ -513,9 +490,7 @@ function cmdRedactBody(kind, number, bodyFile) {
|
||||
if (!kind || !number || !bodyFile) {
|
||||
fail("Usage: redact-body <issue|pr> <number> <redacted-body-file>");
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
|
||||
const endpoint =
|
||||
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
|
||||
@@ -534,12 +509,8 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
|
||||
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(currentBodyFile)) {
|
||||
fail(`File not found: ${currentBodyFile}`);
|
||||
}
|
||||
if (!fs.existsSync(redactedBodyFile)) {
|
||||
fail(`File not found: ${redactedBodyFile}`);
|
||||
}
|
||||
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
|
||||
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
|
||||
|
||||
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
|
||||
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
|
||||
@@ -570,9 +541,7 @@ function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile,
|
||||
* Delete a comment (and all its edit history).
|
||||
*/
|
||||
function cmdDeleteComment(commentId) {
|
||||
if (!commentId) {
|
||||
fail("Usage: delete-comment <comment-id>");
|
||||
}
|
||||
if (!commentId) fail("Usage: delete-comment <comment-id>");
|
||||
gh(["api", `repos/${REPO}/issues/comments/${commentId}`, "-X", "DELETE"], { json: false });
|
||||
console.log(JSON.stringify({ ok: true, deleted_comment_id: Number(commentId) }));
|
||||
}
|
||||
@@ -582,9 +551,7 @@ function cmdDeleteComment(commentId) {
|
||||
* Delete a discussion comment via GraphQL (and all its edit history).
|
||||
*/
|
||||
function cmdDeleteDiscussionComment(nodeId) {
|
||||
if (!nodeId) {
|
||||
fail("Usage: delete-discussion-comment <node-id>");
|
||||
}
|
||||
if (!nodeId) fail("Usage: delete-discussion-comment <node-id>");
|
||||
const result = ghGraphQL(
|
||||
`mutation { deleteDiscussionComment(input: { id: "${nodeId}" }) { comment { id } } }`,
|
||||
);
|
||||
@@ -599,12 +566,9 @@ function cmdDeleteDiscussionComment(nodeId) {
|
||||
* Create a new discussion comment via GraphQL.
|
||||
*/
|
||||
function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId) {
|
||||
if (!discussionNodeId || !bodyFile) {
|
||||
if (!discussionNodeId || !bodyFile)
|
||||
fail("Usage: recreate-discussion-comment <discussion-node-id> <body-file> [reply-to-node-id]");
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
|
||||
const body = fs.readFileSync(bodyFile, "utf8");
|
||||
const newComment = createDiscussionComment(discussionNodeId, body, replyToNodeId);
|
||||
@@ -622,12 +586,8 @@ function cmdRecreateDiscussionComment(discussionNodeId, bodyFile, replyToNodeId)
|
||||
* Create a new comment from a file.
|
||||
*/
|
||||
function cmdRecreateComment(issueNumber, bodyFile) {
|
||||
if (!issueNumber || !bodyFile) {
|
||||
fail("Usage: recreate-comment <issue-number> <body-file>");
|
||||
}
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
fail(`File not found: ${bodyFile}`);
|
||||
}
|
||||
if (!issueNumber || !bodyFile) fail("Usage: recreate-comment <issue-number> <body-file>");
|
||||
if (!fs.existsSync(bodyFile)) fail(`File not found: ${bodyFile}`);
|
||||
|
||||
const result = gh([
|
||||
"api",
|
||||
@@ -755,9 +715,7 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
|
||||
* Close a secret scanning alert.
|
||||
*/
|
||||
function cmdResolve(alertNumber, resolution, comment) {
|
||||
if (!alertNumber) {
|
||||
fail("Usage: resolve <alert-number> [resolution] [comment]");
|
||||
}
|
||||
if (!alertNumber) fail("Usage: resolve <alert-number> [resolution] [comment]");
|
||||
|
||||
const res = resolution || "revoked";
|
||||
const resComment = comment || "Content redacted and author notified to rotate credentials.";
|
||||
@@ -815,12 +773,8 @@ function cmdListOpen() {
|
||||
* Print a formatted summary table from a JSON results file.
|
||||
*/
|
||||
function cmdSummary(jsonFile) {
|
||||
if (!jsonFile) {
|
||||
fail("Usage: summary <json-file>");
|
||||
}
|
||||
if (!fs.existsSync(jsonFile)) {
|
||||
fail(`File not found: ${jsonFile}`);
|
||||
}
|
||||
if (!jsonFile) fail("Usage: summary <json-file>");
|
||||
if (!fs.existsSync(jsonFile)) fail(`File not found: ${jsonFile}`);
|
||||
|
||||
const results = JSON.parse(fs.readFileSync(jsonFile, "utf8"));
|
||||
const lines = [];
|
||||
|
||||
@@ -19,7 +19,7 @@ or validating a change without wasting hours.
|
||||
Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
|
||||
1. Inspect the diff and classify the touched surface:
|
||||
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed` (delegates to Crabbox/Testbox)
|
||||
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
|
||||
- normal source checkout, tests only: `pnpm test:changed`
|
||||
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
|
||||
@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
use the Crabbox wrapper with the provider that matches the proof surface.
|
||||
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
|
||||
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
|
||||
--provider blacksmith-testbox ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`. For direct AWS
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
@@ -66,7 +66,7 @@ scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
|
||||
|
||||
```bash
|
||||
pnpm changed:lanes --json
|
||||
pnpm check:changed # Crabbox/Testbox changed typecheck/lint/guards; no Vitest
|
||||
pnpm check:changed # changed typecheck/lint/guards; no Vitest
|
||||
pnpm test:changed # cheap smart changed Vitest targets
|
||||
pnpm verify # full check, then full Vitest
|
||||
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
|
||||
|
||||
@@ -16,10 +16,6 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
||||
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
|
||||
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
|
||||
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
|
||||
- Full Release Validation parent monitors fail fast: once a required child job
|
||||
fails, the parent cancels the remaining child matrix and prints the failed
|
||||
job summary. Inspect that first red job instead of waiting for unrelated
|
||||
matrix tails.
|
||||
|
||||
## Preflight
|
||||
|
||||
@@ -77,9 +73,6 @@ gh workflow run full-release-validation.yml \
|
||||
```
|
||||
|
||||
Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Use narrow `rerun_group` after focused fixes.
|
||||
Publish with `openclaw-release-publish.yml` using `release_profile=from-validation`
|
||||
unless a maintainer intentionally wants to cross-check a specific profile; the
|
||||
publish workflow reads the effective profile from the full-validation manifest.
|
||||
|
||||
## Watch
|
||||
|
||||
|
||||
@@ -49,21 +49,17 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
the next beta number until the matching npm package has actually published.
|
||||
If a published beta needs a fix, commit the fix on the release branch and
|
||||
increment to the next `-beta.N`.
|
||||
- For a beta release train, keep Full Release Validation as a pre-publish gate
|
||||
unless the operator explicitly waives it. Run the fast local preflight, npm
|
||||
preflight, full release validation, and performance in parallel where safe.
|
||||
If anything fails before npm publish, fix it on the release branch,
|
||||
forward-port the fix to `main`, move the unpublished beta tag/prerelease to
|
||||
the fixed commit, and rerun the affected pre-publish gates. If anything fails
|
||||
after npm publish, fix it, forward-port to `main`, increment beta number, and
|
||||
repeat. After each beta publish, run the published-package roster focused on
|
||||
install/update/Docker/Parallels/NPM Telegram. For later beta attempts, rerun
|
||||
only lanes whose evidence changed unless the fix touches broad release,
|
||||
install/update, plugin, Docker, Parallels, or live QA behavior. After each
|
||||
beta is live, scan current `main` once for critical fixes that landed after
|
||||
the release branch cut and backport only important low-risk fixes. Operators
|
||||
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
|
||||
stop and report.
|
||||
- For a beta release train, run the fast local preflight first, publish the
|
||||
beta to npm `beta`, then run the expensive published-package roster focused
|
||||
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
|
||||
the release branch, commit/push/pull, increment beta number, and repeat. Run
|
||||
the full expensive roster at least once before stable/latest promotion; for
|
||||
later beta attempts, rerun only lanes whose evidence changed unless the fix
|
||||
touches broad release, install/update, plugin, Docker, Parallels, or live QA
|
||||
behavior. After each beta is published, scan current `main` once for critical
|
||||
fixes that landed after the release branch cut and backport only important
|
||||
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
|
||||
after 4 failed beta attempts, stop and report.
|
||||
- As soon as the release candidate SHA exists, dispatch `OpenClaw Performance`
|
||||
with `target_ref=<release-sha>` in parallel with the other release work. Do
|
||||
not wait for full release validation to start the performance signal.
|
||||
@@ -472,10 +468,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- The npm workflow and the private mac publish workflow accept
|
||||
`preflight_only=true` to run validation/build/package steps without uploading
|
||||
public release assets.
|
||||
- Real npm publish requires a prior successful npm preflight run id and the
|
||||
successful Full Release Validation run id for the same tag/SHA so the publish
|
||||
job promotes the prepared tarball instead of rebuilding it and attaches the
|
||||
correct release evidence.
|
||||
- Real npm publish requires a prior successful npm preflight run id so the
|
||||
publish job promotes the prepared tarball instead of rebuilding it.
|
||||
- Real private mac publish requires a prior successful private mac preflight
|
||||
run id so the publish job promotes the prepared artifacts instead of
|
||||
rebuilding or renotarizing them again.
|
||||
@@ -505,12 +499,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
instead of uploading public GitHub release assets.
|
||||
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
|
||||
workflow artifacts and intentionally skip stable `appcast.xml` generation.
|
||||
- For stable releases, npm preflight, Full Release Validation, public mac
|
||||
validation, private mac validation, and private mac preflight must all pass
|
||||
before any real publish run starts. For beta releases, npm preflight and Full
|
||||
Release Validation must pass before npm publish unless the operator explicitly
|
||||
waives the full gate; mac beta validation is still only required when
|
||||
requested.
|
||||
- For stable releases, npm preflight, public mac validation, private mac
|
||||
validation, and private mac preflight must all pass before any real publish
|
||||
run starts. For beta releases, npm preflight plus the selected Docker,
|
||||
install/update, Parallels, and release-check lanes are sufficient unless mac
|
||||
beta validation was explicitly requested.
|
||||
- Real publish runs may be dispatched from `main` or from a
|
||||
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
|
||||
in that release branch, and the real publish must reuse a successful preflight
|
||||
|
||||
@@ -4,11 +4,11 @@ profile: openclaw-check
|
||||
provider: azure
|
||||
class: standard
|
||||
capacity:
|
||||
market: on-demand
|
||||
market: spot
|
||||
strategy: most-available
|
||||
# The Azure-backed billing account carries the OpenClaw runner credits; use
|
||||
# explicit on-demand capacity instead of low-priority spot, whose regional
|
||||
# quota is too small for broad maintainer proof or parallel Crabbox lanes.
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
hints: true
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
@@ -28,30 +28,11 @@ blacksmith:
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
cache:
|
||||
pnpm: true
|
||||
npm: true
|
||||
git: true
|
||||
volumes:
|
||||
- name: pnpm
|
||||
key: openclaw-linux-node24-pnpm
|
||||
path: /var/cache/crabbox/pnpm
|
||||
sizeGB: 80
|
||||
required: false
|
||||
- name: npm
|
||||
key: openclaw-linux-node24-npm
|
||||
path: /var/cache/crabbox/npm
|
||||
sizeGB: 40
|
||||
required: false
|
||||
aws:
|
||||
# AWS-specific overrides still pin direct `--provider aws` runs without
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
azure:
|
||||
# The OpenClaw Azure subscription is reliable in eastus2; eastus rejects the
|
||||
# same SKUs and can stall provisioning.
|
||||
location: eastus2
|
||||
sync:
|
||||
delete: true
|
||||
checksum: false
|
||||
@@ -71,64 +52,4 @@ env:
|
||||
- OPENCLAW_*
|
||||
ssh:
|
||||
user: crabbox
|
||||
# Azure coordinator leases expose SSH on 22. The run wrapper can fall back
|
||||
# from 2222, but `crabbox job run` hydrates via the configured port directly.
|
||||
port: "22"
|
||||
jobs:
|
||||
prewarm:
|
||||
provider: azure
|
||||
target: linux
|
||||
class: standard
|
||||
type: Standard_D4ads_v6
|
||||
market: on-demand
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: true
|
||||
waitTimeout: 20m
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
job: hydrate
|
||||
ref: main
|
||||
noSync: true
|
||||
shell: true
|
||||
command: "true"
|
||||
stop: never
|
||||
changed:
|
||||
provider: azure
|
||||
target: linux
|
||||
class: standard
|
||||
type: Standard_D4ads_v6
|
||||
market: on-demand
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: true
|
||||
waitTimeout: 20m
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
job: hydrate
|
||||
ref: main
|
||||
shell: true
|
||||
command: |
|
||||
set -euo pipefail
|
||||
if ! git status --short >/dev/null 2>&1; then
|
||||
rm -rf .git
|
||||
git init -q
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-check-tree
|
||||
fi
|
||||
fi
|
||||
env CI=1 corepack pnpm check --timed
|
||||
stop: always
|
||||
testbox-changed:
|
||||
provider: blacksmith-testbox
|
||||
target: linux
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: false
|
||||
actions:
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
command: env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed
|
||||
stop: always
|
||||
port: "2222"
|
||||
|
||||
1
.github/actions/setup-node-env/action.yml
vendored
1
.github/actions/setup-node-env/action.yml
vendored
@@ -128,7 +128,6 @@ runs:
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}"
|
||||
fi
|
||||
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
|
||||
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@@ -301,12 +301,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/memory-lancedb/**"
|
||||
"extensions: llama-cpp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/llama-cpp/**"
|
||||
- "docs/plugins/llama-cpp.md"
|
||||
- "docs/plugins/reference/llama-cpp.md"
|
||||
"extensions: memory-wiki":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
156
.github/workflows/ci-check-arm-testbox.yml
vendored
156
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -1,156 +0,0 @@
|
||||
name: Blacksmith ARM Testbox
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
|
||||
jobs:
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
3
.github/workflows/ci-check-testbox.yml
vendored
3
.github/workflows/ci-check-testbox.yml
vendored
@@ -15,8 +15,9 @@ permissions:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
|
||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -605,19 +605,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
- name: Restore dist build cache
|
||||
id: dist_build_cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
extensions/*/src/host/**/.bundle.hash
|
||||
extensions/*/src/host/**/*.bundle.js
|
||||
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
|
||||
|
||||
- name: Build dist
|
||||
if: steps.dist_build_cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build:ci-artifacts
|
||||
@@ -626,6 +614,14 @@ jobs:
|
||||
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
||||
run: pnpm ui:i18n:check
|
||||
|
||||
- name: Cache dist build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
|
||||
|
||||
- name: Pack built runtime artifacts
|
||||
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
|
||||
|
||||
@@ -755,18 +751,6 @@ jobs:
|
||||
done
|
||||
exit "$failures"
|
||||
|
||||
- name: Save dist build cache
|
||||
if: steps.dist_build_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
extensions/*/src/host/**/.bundle.hash
|
||||
extensions/*/src/host/**/*.bundle.js
|
||||
key: ${{ steps.dist_build_cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1095,7 +1079,6 @@ jobs:
|
||||
node openclaw.mjs --help
|
||||
node openclaw.mjs status --json --timeout 1
|
||||
pnpm test:build:singleton
|
||||
|
||||
checks-node-core-test-nondist-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1167,8 +1150,7 @@ jobs:
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
||||
OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1"
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -1219,9 +1201,6 @@ jobs:
|
||||
- check_name: check-guards
|
||||
task: guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-shrinkwrap
|
||||
task: shrinkwrap
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1297,6 +1276,7 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:shrinkwrap:check
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
@@ -1305,9 +1285,6 @@ jobs:
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
shrinkwrap)
|
||||
pnpm deps:shrinkwrap:check
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
;;
|
||||
@@ -1426,7 +1403,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/plugins/types.ts', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
|
||||
45
.github/workflows/crabbox-hydrate.yml
vendored
45
.github/workflows/crabbox-hydrate.yml
vendored
@@ -32,11 +32,11 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_STORE_DIR: "/var/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm-virtual-store"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
hydrate:
|
||||
@@ -120,25 +120,9 @@ jobs:
|
||||
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
|
||||
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
|
||||
reset_crabbox_pnpm_path() {
|
||||
local path="$1"
|
||||
if [ -z "$path" ]; then
|
||||
return
|
||||
fi
|
||||
case "$path" in
|
||||
/var/tmp/openclaw-pnpm-*) rm -rf "$path" ;;
|
||||
esac
|
||||
}
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_MODULES_DIR:-}"
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_STORE_DIR:-}"
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_VIRTUAL_STORE_DIR:-}"
|
||||
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
rm -f node_modules
|
||||
fi
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
export NODE_PATH="$PNPM_CONFIG_MODULES_DIR${NODE_PATH:+:$NODE_PATH}"
|
||||
fi
|
||||
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
@@ -373,8 +357,8 @@ jobs:
|
||||
$env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
|
||||
$env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
|
||||
$env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
|
||||
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $cacheRoot "openclaw-pnpm-node-modules"
|
||||
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $env:PNPM_CONFIG_MODULES_DIR ".pnpm"
|
||||
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $workspace "node_modules"
|
||||
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $workspace "node_modules\.pnpm"
|
||||
$env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
|
||||
$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
|
||||
$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"
|
||||
@@ -446,25 +430,6 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
$workspaceNodeModules = Join-Path $workspace "node_modules"
|
||||
if (Test-Path $workspaceNodeModules) {
|
||||
$workspaceNodeModulesItem = Get-Item $workspaceNodeModules -Force
|
||||
if (($workspaceNodeModulesItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) {
|
||||
$nodeModulesChildren = @(Get-ChildItem -LiteralPath $workspaceNodeModules -Force)
|
||||
$hasOnlyPnpmWorkspaceState = $nodeModulesChildren.Count -eq 1 -and $nodeModulesChildren[0].Name -eq ".pnpm-workspace-state-v1.json"
|
||||
if ($nodeModulesChildren.Count -ne 0 -and -not $hasOnlyPnpmWorkspaceState) {
|
||||
throw "workspace node_modules exists and is not a link: $workspaceNodeModules"
|
||||
}
|
||||
foreach ($nodeModulesChild in $nodeModulesChildren) {
|
||||
Remove-Item -LiteralPath $nodeModulesChild.FullName -Force
|
||||
}
|
||||
Remove-Item -LiteralPath $workspaceNodeModules -Force
|
||||
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
|
||||
}
|
||||
} else {
|
||||
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
|
||||
}
|
||||
|
||||
$corepackShimDir = Join-Path $nodeBin "node_modules\corepack\shims"
|
||||
if (Test-Path $corepackShimDir) {
|
||||
$env:PNPM_HOME = $corepackShimDir
|
||||
|
||||
7
.github/workflows/docker-release.yml
vendored
7
.github/workflows/docker-release.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-alpha.*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
@@ -39,11 +38,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
echo "Docker alpha image publishing is disabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid release tag: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.x"
|
||||
node-version: "22.19.0"
|
||||
|
||||
- name: Clone publish repo
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
|
||||
122
.github/workflows/full-release-validation.yml
vendored
122
.github/workflows/full-release-validation.yml
vendored
@@ -229,7 +229,7 @@ jobs:
|
||||
needs: [resolve_target]
|
||||
if: inputs.rerun_group == 'all'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -245,11 +245,54 @@ jobs:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --kill-after=30s 15m docker build \
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
.
|
||||
|
||||
- name: Build and smoke test final Docker runtime image
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image_ref="openclaw-release-runtime-smoke:${TARGET_SHA}"
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
-t "${image_ref}" \
|
||||
.
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
test -f /app/src/agents/templates/HEARTBEAT.md
|
||||
temp_root="$(mktemp -d)"
|
||||
trap "rm -rf \"${temp_root}\"" EXIT
|
||||
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
|
||||
cd "${temp_root}/cwd"
|
||||
set +e
|
||||
HOME="${temp_root}/home" \
|
||||
USERPROFILE="${temp_root}/home" \
|
||||
OPENCLAW_HOME="${temp_root}/home" \
|
||||
OPENCLAW_NO_ONBOARD=1 \
|
||||
OPENCLAW_SUPPRESS_NOTES=1 \
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
|
||||
AWS_EC2_METADATA_DISABLED=true \
|
||||
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
|
||||
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
|
||||
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
|
||||
>"${temp_root}/out.log" 2>&1
|
||||
status="$?"
|
||||
set -e
|
||||
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
|
||||
cat "${temp_root}/out.log"
|
||||
exit 1
|
||||
fi
|
||||
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
|
||||
if [ "${status}" -ne 0 ]; then
|
||||
cat "${temp_root}/out.log"
|
||||
fi
|
||||
'
|
||||
|
||||
normal_ci:
|
||||
name: Run normal full CI
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
@@ -337,21 +380,6 @@ jobs:
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
failed_jobs_json="$(
|
||||
fetch_child_jobs |
|
||||
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
|
||||
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -367,9 +395,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
@@ -485,21 +510,6 @@ jobs:
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
failed_jobs_json="$(
|
||||
fetch_child_jobs |
|
||||
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
|
||||
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -515,9 +525,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
@@ -683,24 +690,6 @@ jobs:
|
||||
[[ "$saw_advisory" == "1" && "$failed" == "0" ]]
|
||||
}
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
failed_jobs_json="$(
|
||||
fetch_child_jobs |
|
||||
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
|
||||
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -716,9 +705,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
@@ -976,21 +962,6 @@ jobs:
|
||||
}
|
||||
trap cancel_child EXIT INT TERM
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
failed_jobs_json="$(
|
||||
gh_with_retry run view "$run_id" --json jobs \
|
||||
--jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run."
|
||||
jq '.[] | {name, conclusion, url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh_with_retry run view "$run_id" --json status --jq '.status')"
|
||||
@@ -998,9 +969,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
|
||||
16
.github/workflows/labeler.yml
vendored
16
.github/workflows/labeler.yml
vendored
@@ -115,6 +115,7 @@ jobs:
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const labelNames = new Set(currentLabels.map((label) => label.name ?? ""));
|
||||
|
||||
for (const label of currentLabels) {
|
||||
const name = label.name ?? "";
|
||||
@@ -130,14 +131,17 @@ jobs:
|
||||
issue_number: pullRequest.number,
|
||||
name,
|
||||
});
|
||||
labelNames.delete(name);
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
if (!labelNames.has(targetSizeLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
|
||||
@@ -1953,7 +1953,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-minimax
|
||||
label: Native live gateway profiles MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
@@ -2252,7 +2252,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M3,minimax-portal/MiniMax-M3 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
6
.github/workflows/openclaw-npm-release.yml
vendored
6
.github/workflows/openclaw-npm-release.yml
vendored
@@ -51,8 +51,7 @@ jobs:
|
||||
# so this source workflow can stay focused on OIDC publish only.
|
||||
preflight_openclaw_npm:
|
||||
if: ${{ inputs.preflight_only }}
|
||||
# Preflight builds the full release package before publish; ubuntu-latest can OOM in tsdown.
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -257,8 +256,7 @@ jobs:
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (const match of input.matchAll(/\[/g)) {
|
||||
const start = match.index;
|
||||
for (let start = input.indexOf("["); start !== -1; start = input.indexOf("[", start + 1)) {
|
||||
const end = arrayEndFrom(start);
|
||||
if (end === -1) {
|
||||
continue;
|
||||
|
||||
20
.github/workflows/openclaw-release-checks.yml
vendored
20
.github/workflows/openclaw-release-checks.yml
vendored
@@ -798,7 +798,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run parity lane
|
||||
env:
|
||||
@@ -876,7 +876,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Generate parity report
|
||||
run: |
|
||||
@@ -903,7 +903,7 @@ jobs:
|
||||
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
@@ -934,7 +934,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run runtime parity lane
|
||||
id: runtime_parity_lane
|
||||
@@ -1075,7 +1075,7 @@ jobs:
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_matrix_enabled == 'true'
|
||||
continue-on-error: true
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1101,7 +1101,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
id: run_lane
|
||||
@@ -1199,7 +1199,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
id: run_lane
|
||||
@@ -1295,7 +1295,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
id: run_lane
|
||||
@@ -1393,7 +1393,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
id: run_lane
|
||||
@@ -1488,7 +1488,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
id: run_lane
|
||||
|
||||
25
.github/workflows/openclaw-release-publish.yml
vendored
25
.github/workflows/openclaw-release-publish.yml
vendored
@@ -46,12 +46,11 @@ on:
|
||||
default: true
|
||||
type: boolean
|
||||
release_profile:
|
||||
description: Release coverage profile used for release evidence summaries; default reads it from the validation manifest
|
||||
description: Release coverage profile used for release evidence summaries
|
||||
required: false
|
||||
default: from-validation
|
||||
default: beta
|
||||
type: choice
|
||||
options:
|
||||
- from-validation
|
||||
- beta
|
||||
- stable
|
||||
- full
|
||||
@@ -136,9 +135,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
case "$RELEASE_PROFILE" in
|
||||
from-validation|beta|stable|full) ;;
|
||||
beta|stable|full) ;;
|
||||
*)
|
||||
echo "release_profile must be one of: from-validation, beta, stable, full" >&2
|
||||
echo "release_profile must be one of: beta, stable, full" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -260,7 +259,6 @@ jobs:
|
||||
echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate full release validation manifest
|
||||
id: full_manifest
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -291,7 +289,7 @@ jobs:
|
||||
echo "Full release validation target SHA mismatch: expected $EXPECTED_SHA, got $target_sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$EXPECTED_RELEASE_PROFILE" != "from-validation" && "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
|
||||
if [[ "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
|
||||
echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -299,7 +297,6 @@ jobs:
|
||||
echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "release_profile=$release_profile" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate release tag is reachable from a trusted release branch
|
||||
env:
|
||||
@@ -335,7 +332,7 @@ jobs:
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
|
||||
RELEASE_PROFILE: ${{ inputs.release_profile }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
run: |
|
||||
{
|
||||
@@ -504,7 +501,7 @@ jobs:
|
||||
wait_for_run() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json
|
||||
local status conclusion url updated_at created_at duration_seconds duration_label last_state
|
||||
|
||||
last_state=""
|
||||
while true; do
|
||||
@@ -513,14 +510,6 @@ jobs:
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
|
||||
--jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' || true)"
|
||||
if [[ -n "${failed_json}" ]] && jq -e 'length > 0' <<< "$failed_json" >/dev/null; then
|
||||
echo "${workflow} has failed jobs before the workflow completed: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2
|
||||
jq '.[] | {name, conclusion, url}' <<< "$failed_json" >&2 || true
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
url="$(printf '%s' "$run_json" | jq -r '.url')"
|
||||
updated_at="$(printf '%s' "$run_json" | jq -r '.updatedAt')"
|
||||
state="${status}:${updated_at}"
|
||||
|
||||
@@ -818,7 +818,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.slack_scenario || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/update-migration.yml
vendored
2
.github/workflows/update-migration.yml
vendored
@@ -43,4 +43,4 @@ jobs:
|
||||
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
|
||||
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
|
||||
telegram_mode: none
|
||||
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.
|
||||
secrets: inherit
|
||||
|
||||
4
.github/workflows/windows-testbox-probe.yml
vendored
4
.github/workflows/windows-testbox-probe.yml
vendored
@@ -61,14 +61,12 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Probe native Windows
|
||||
env:
|
||||
TARGET_REF: ${{ inputs.target_ref || github.ref }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Write-Host "runner=$env:RUNNER_NAME"
|
||||
Write-Host "machine=$env:COMPUTERNAME"
|
||||
Write-Host "workspace=$env:GITHUB_WORKSPACE"
|
||||
Write-Host "target_ref=$env:TARGET_REF"
|
||||
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
|
||||
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
|
||||
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
|
||||
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())
|
||||
|
||||
68
.github/workflows/workflow-sanity.yml
vendored
68
.github/workflows/workflow-sanity.yml
vendored
@@ -84,65 +84,6 @@ jobs:
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Prepare trusted workflow audit configs
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
||||
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
|
||||
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
|
||||
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
|
||||
> "$trusted_config" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
|
||||
else
|
||||
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
|
||||
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
|
||||
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
|
||||
> "$trusted_zizmor_config" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
|
||||
else
|
||||
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
zizmor_config_path = sys.argv[2]
|
||||
text = config_path.read_text()
|
||||
if ".github/zizmor.yml" not in text:
|
||||
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
|
||||
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
|
||||
PY
|
||||
|
||||
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install pre-commit
|
||||
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -162,15 +103,6 @@ jobs:
|
||||
- name: Lint workflows
|
||||
run: actionlint
|
||||
|
||||
- name: Audit all workflows with zizmor
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t workflow_files < <(
|
||||
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
|
||||
)
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -42,7 +42,6 @@ apps/macos-mlx-tts/.build/
|
||||
apps/shared/MoltbotKit/.build/
|
||||
apps/shared/OpenClawKit/.build/
|
||||
apps/shared/*/.build/
|
||||
packages/*/dist/
|
||||
apps/shared/OpenClawKit/Package.resolved
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
@@ -60,6 +59,8 @@ apps/ios/.swiftpm/
|
||||
apps/ios/.derivedData/
|
||||
apps/ios/.local-signing.xcconfig
|
||||
vendor/
|
||||
!src/auto-reply/reply/export-html/vendor/
|
||||
!src/auto-reply/reply/export-html/vendor/**
|
||||
apps/ios/Clawdbot.xcodeproj/
|
||||
apps/ios/Clawdbot.xcodeproj/**
|
||||
apps/macos/.build/**
|
||||
@@ -102,9 +103,13 @@ USER.md
|
||||
# though the bare names match the local-untracked rule above.
|
||||
!extensions/oc-path/src/oc-path/tests/fixtures/real/IDENTITY.md
|
||||
!extensions/oc-path/src/oc-path/tests/fixtures/real/USER.md
|
||||
!docs/reference/templates/IDENTITY.md
|
||||
!docs/reference/templates/USER.md
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
*.zip
|
||||
!test/fixtures/plugins-install/*.tgz
|
||||
!test/fixtures/plugins-install/*.zip
|
||||
.idea
|
||||
.vscode/
|
||||
|
||||
@@ -129,10 +134,7 @@ mantis/
|
||||
!.agents/skills/control-ui-e2e/**
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/technical-documentation/
|
||||
!.agents/skills/technical-documentation/**
|
||||
!.agents/skills/openclaw-refactor-docs/
|
||||
!.agents/skills/openclaw-refactor-docs/**
|
||||
!.agents/skills/openclaw-docs/**
|
||||
!.agents/skills/openclaw-debugging/
|
||||
!.agents/skills/openclaw-debugging/**
|
||||
!.agents/skills/openclaw-ghsa-maintainer/
|
||||
|
||||
@@ -8,7 +8,26 @@
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint/no-underscore-dangle": "error",
|
||||
"eslint/no-underscore-dangle": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"__openclaw",
|
||||
"__test",
|
||||
"__testing",
|
||||
"__resetUsageFormatCachesForTest",
|
||||
"_createdAt",
|
||||
"_default",
|
||||
"_getActiveHandles",
|
||||
"_getActiveRequests",
|
||||
"_registerProvider",
|
||||
"_resetActiveManagedProxyStateForTests",
|
||||
"_resetIMessageShortIdMemoryForTest",
|
||||
"_resetIMessageShortIdState",
|
||||
"_setGitHubCopilotDeviceFlowFetchGuardForTesting"
|
||||
]
|
||||
}
|
||||
],
|
||||
"eslint-plugin-unicorn/prefer-array-find": "error",
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
@@ -22,12 +41,11 @@
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-param-reassign": "error",
|
||||
"eslint/no-proto": "error",
|
||||
"eslint/no-promise-executor-return": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-return-assign": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
"eslint/no-self-compare": "error",
|
||||
"eslint/no-shadow": "error",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-implicit-coercion": "error",
|
||||
"eslint/no-var": "error",
|
||||
"eslint/no-useless-call": "error",
|
||||
@@ -36,8 +54,7 @@
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-useless-rename": "error",
|
||||
"eslint/no-useless-return": "error",
|
||||
"eslint/no-useless-assignment": "error",
|
||||
"eslint/no-unused-vars": "error",
|
||||
"eslint/no-unused-vars": "off",
|
||||
"eslint/no-warning-comments": "error",
|
||||
"eslint/no-unmodified-loop-condition": "error",
|
||||
"eslint/no-new-wrappers": "error",
|
||||
@@ -80,12 +97,8 @@
|
||||
"typescript/no-extraneous-class": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
"typescript/no-non-null-asserted-nullish-coalescing": "error",
|
||||
"typescript/prefer-promise-reject-errors": "error",
|
||||
"typescript/restrict-plus-operands": "error",
|
||||
"typescript/no-unnecessary-qualifier": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/no-unnecessary-type-arguments": "error",
|
||||
@@ -112,8 +125,6 @@
|
||||
"typescript/require-array-sort-compare": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/use-unknown-in-catch-callback-variable": "error",
|
||||
"unicorn/consistent-date-clone": "error",
|
||||
"unicorn/consistent-empty-array-spread": "error",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
@@ -133,7 +144,6 @@
|
||||
"unicorn/no-unnecessary-slice-end": "error",
|
||||
"unicorn/no-useless-error-capture-stack-trace": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-useless-switch-case": "error",
|
||||
"unicorn/no-zero-fractions": "error",
|
||||
"unicorn/prefer-date-now": "error",
|
||||
"unicorn/prefer-dom-node-text-content": "error",
|
||||
@@ -238,7 +248,9 @@
|
||||
"**/*test-support.ts"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "off"
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"eslint/no-unsafe-optional-chaining": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
77
AGENTS.md
77
AGENTS.md
@@ -9,11 +9,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
|
||||
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
|
||||
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
|
||||
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
|
||||
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.
|
||||
- Dependency-touching work: direct dependency inspection is mandatory when feasible; do not rely on assumptions, wrappers, or memory. Most dependencies are OSS, so read their source/docs/types. Codex-related work has a hard gate: the acting agent must personally inspect sibling `../codex` source for the exact protocol/runtime behavior before any verdict, comment, approval, merge recommendation, code change, or `proof sufficient` claim. If missing, clone `https://github.com/openai/codex.git` there first. Subagent reports, PR text, OpenClaw wrappers, generated schemas, memory, and prior bot reviews do not satisfy this gate. No direct `../codex` check means no Codex verdict. Cite Codex files/lines checked in final/review/comment.
|
||||
- Dependency-backed behavior: read upstream docs/source/types first. No API/default/error/timing guesses.
|
||||
- External API work: live test required. Google/search for additional proof. Prefer official docs/source/types; cite current proof. No memory-only API claims.
|
||||
- Live-verify when feasible. Never print secrets.
|
||||
- Missing deps: `pnpm install`, retry once, then report first actionable error.
|
||||
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
|
||||
@@ -30,8 +26,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Plugin APIs, provider routing, auth/session state, persisted preferences, config loading, config/default additions, migrations, setup, startup checks, and fallback behavior are compatibility/upgrade-sensitive. Treat config breaks, new config/default surfaces, removed fallbacks, fail-closed changes, stricter validation, or new operator action as merge risk even with green CI when they can affect existing users, upgrades, provider/plugin behavior, or maintainer operations.
|
||||
- For PRs that add, remove, or change config/default surfaces with possible compatibility, upgrade, provider/plugin, operator, setup, startup, or fallback impact, ClawSweeper review should emit a `reviewMetrics` entry when practical. The metric should name the count and direction of the changes, such as added, changed, or removed config/default surfaces, and explain why the metric matters before merge. When the metric indicates concrete merge risk, also surface the concern in `risks`, use `mergeRiskLabels` when the risk matches the label rubric, make `bestSolution` name the desired pre-merge state, and ensure `labelJustifications` explain the specific reason rather than restating the label.
|
||||
- Review whole decision surfaces, not only the touched runtime, provider, channel, harness, plugin seam, or context path. Check sibling Codex/Pi-style runtimes, provider/model routing, channel delivery, gateway/protocol, plugin SDK, and context-management paths when relevant.
|
||||
- Every PR review must explicitly ask whether the PR is the best fix, not merely a plausible fix. Verdicts need a best-fix judgment backed by enough code reading to compare owner boundaries, callers, siblings, tests, docs, current `main`, shipped behavior when relevant, and dependency/Codex contracts when involved.
|
||||
- Before a PR verdict, build a small evidence map: changed surface, entry point, owner boundary, at least one caller and callee, sibling surfaces that share the invariant, existing tests, and current `main` behavior. If any cell is missing, say the gap instead of concluding.
|
||||
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
|
||||
- Changelog findings: see Docs / Changelog.
|
||||
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
|
||||
@@ -63,25 +57,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
|
||||
- OpenAI Codex is folded into `openai`. No new/live `openai-codex` provider/plugin/auth/model routes; treat them as legacy input only. Runtime/setup/auth/catalog use `openai` + `openai/*`; doctor/migrations repair stale `openai-codex/*` profiles/metadata.
|
||||
- Config/env surface bar is high; `openclaw.json` and environment variables are already large. Before adding a config option or env var, first prove existing product behavior, provider selection, defaults, or doctor migration cannot solve it. Prefer removing or consolidating config/env options when touching these surfaces. Core supports only the latest config shape; `openclaw doctor --fix` migrates older shipped shapes into the current one.
|
||||
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
|
||||
- Tests may use observed examples, but prod literals need a short contract reason.
|
||||
- Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped.
|
||||
- Refactor default: one canonical path. Delete the old path unless user explicitly wants compat or the shipped public contract is obvious and cited.
|
||||
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
|
||||
- State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime.
|
||||
- Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data.
|
||||
- SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
|
||||
- Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents/<agentId>/agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
|
||||
- Legacy state/cache files are migration debt. When touching code that reads/writes them, prefer moving the data into SQLite or calling out the refactor follow-up; do not add parallel file paths.
|
||||
- File storage must be a named product artifact: import/export, user attachment, log, backup, or external tool contract. If it is app state or cache, it belongs in SQLite.
|
||||
- Before adding any path under state dirs, choose one: shared state DB, plugin KV, agent DB, or dedicated SQLite schema. If none fits, design the SQLite owner/schema first.
|
||||
- Cache/transient state gets no compat migration unless a shipped user contract is cited. Prefer delete/drop/rebuild over import. If old state can be lost without user-visible data loss, remove the old path entirely.
|
||||
- Persistent user state gets one migration owner. Doctor migrates, verifies, and then runtime assumes the new shape. No dual-write, read-through fallback, lazy import, or "if SQLite fails use JSON" branches.
|
||||
- Fallback is a product decision, not an implementation convenience. Before adding one, name the shipped contract, failure mode, removal plan, and why doctor cannot solve it. Otherwise delete it.
|
||||
- Keep old behavior only for an explicit public API/config/plugin SDK/data contract, tagged upgrade path, security/migration boundary, dependency contract, or observed prod state.
|
||||
- If unsure, ask before preserving compat. Do not keep aliases, shims, fallback stacks, stale names, or obsolete tests just in case.
|
||||
- Tests alone do not make internals contracts. If compat stays, name the contract and migration/removal plan in code, test, or PR.
|
||||
@@ -91,9 +72,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Plugin SDK exception: shipped external API gets new API first plus named compat/deprecation, small tests/docs if useful, removal plan.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Message/channel plugins stay transport-only. They render portable presentation/actions, enforce transport limits, and map native callback envelopes. They do not own product command trees, plugin/provider policy, or feature-specific menus.
|
||||
- Portable command UI must use typed presentation actions, not raw string inference. Do not make channels guess that `value` starting with `/` means a native command; core/owner plugins declare command actions, channels map them when supported.
|
||||
- Raw callback data is transport/private. Approval, command, URL, web-app, and select actions must stay distinguishable before channel encoding so transport adapters do not special-case product strings.
|
||||
- Agent run terminal state: normalize/merge via `src/agents/agent-run-terminal-outcome.ts`; do not rederive timeout/cancel precedence in projections.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
|
||||
@@ -117,8 +95,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
- Checks in a normal source checkout: `pnpm check:changed` delegates to Crabbox/Testbox; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` so pnpm runs inside Testbox, not locally.
|
||||
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
|
||||
@@ -128,13 +106,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
|
||||
- Pre-land/pre-commit code changes: mandatory fresh `$autoreview` until no accepted/actionable findings remain. Do not land code on CI, ClawSweeper, prior review comments, or your own manual review alone unless user explicitly opts out or scope is truly trivial/docs-only. If findings want refactor, refactor; no ugly fixes.
|
||||
- Pre-land/pre-commit code changes: use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
|
||||
- If proof is blocked, say exactly what is missing and why.
|
||||
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
|
||||
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
|
||||
@@ -143,35 +120,27 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## GitHub / PRs
|
||||
|
||||
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
|
||||
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
|
||||
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
|
||||
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
|
||||
- Bare issue/PR URL/number: inspect live and take the efficient maintainer path; switch branches/refs when useful.
|
||||
- No unsolicited PR labels/retitles/rebases/fixups/landing. Comments/reviews ok only for reviewable findings, pre-merge proof, or close/duplicate reason after explicit close/sweep/landing request.
|
||||
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
|
||||
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
|
||||
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
|
||||
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
|
||||
- Issue/PR work: search strong related issues/PRs before final; close proven dupes/fixed siblings. If none close, suggest one next related follow-up.
|
||||
- PR superseded by `main`: if code proof shows `main` already has same-or-better behavior, comment canonical commit/PR + focused proof, then close. Bar high: inspect PR diff, current code/tests, linked issue, caller/sibling path. If unsure, leave open.
|
||||
- Issue/PR numbers need a short summary every time; assume the reader has not opened or read them.
|
||||
- Before presenting a batch of issues/PRs, use smart subagents to verify live state and current `main`; omit closed/fixed items, and comment+close items already fixed on `main` when maintainer action is authorized.
|
||||
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
|
||||
- PR reviewable findings: post them on the PR, not chat-only, so author sees actionable feedback.
|
||||
- Issue/PR final answer: last line is the full GitHub URL.
|
||||
- PR verification: before merge, post land-ready work done, exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
|
||||
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
|
||||
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
|
||||
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
|
||||
- After landing/ship final: include 2-5 sentence recap of what landed: behavior change, key files/surface, proof run, issue/PR state. Do not answer with only status/links.
|
||||
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
|
||||
- Public GH comments: show draft in chat first unless user explicitly asked to post/comment/reply/close/merge/land. After work starts and changes/proof exist, post the review/proof/commit comment.
|
||||
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
|
||||
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
|
||||
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
|
||||
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
|
||||
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
|
||||
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
|
||||
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
|
||||
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
@@ -185,24 +154,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
|
||||
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
|
||||
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
|
||||
- Code size matters. Prefer small clear code; maintainability includes not growing LOC without payoff.
|
||||
- Refactors should delete about as much local complexity as they add. If LOC grows, the new ownership/API needs to clearly pay for it.
|
||||
- Refactors should reduce non-test LOC unless they remove a larger architectural cost. Treat positive prod LOC as a smell. Before closeout, run `git diff --numstat`; if non-test LOC grew, trim or explicitly justify why fewer paths now exist.
|
||||
- Prefer deleting branches, modes, adapters, and tests over preserving them. A refactor that adds a second path has probably failed unless the old path is a cited shipped contract.
|
||||
- New helpers/files must pay rent immediately: fewer call paths, fewer concepts, or less repeated logic. No helpers for one-off compat, naming translation, or speculative resilience.
|
||||
- Before adding helpers/files, check whether existing code can absorb the behavior with less new surface.
|
||||
- Keep APIs narrow: export only current caller needs; keep types/helpers local by default.
|
||||
- Return the smallest useful shape. Avoid broad result objects, flags, metadata unless callers use them.
|
||||
- Avoid adapter layers that only rename fields. Move real responsibility or leave code local.
|
||||
- Inline simple one-use objects/spreads when clearer. Extract only when it removes duplication or hard logic.
|
||||
- Tests prove behavior/regressions, not every internal branch.
|
||||
- Tests are welcome, but review them before landing for duplication and value. Delete useless tests, such as assertions for behavior or paths just removed.
|
||||
- Tests protect canonical behavior and migration boundaries, not obsolete internals. Delete tests for removed fallback paths instead of updating them.
|
||||
- For non-trivial refactors, check `git diff --numstat` before closeout. If LOC grew, trim or explain why.
|
||||
- Prefer existing narrow helpers over repeated casts/guards. Add local helpers when 2+ nearby call sites share real boundary logic.
|
||||
- Prefer ctor parameter properties for injected deps/config. Do not ban them for erasable-syntax purity.
|
||||
- Prefer `satisfies` for registries/config maps; derive types from schemas when a runtime schema already exists.
|
||||
- Table-drive repetitive tests when it reduces code and keeps failure names clear.
|
||||
- Storage adapters: quarantine schema/nullability mess at the boundary. Use one named mapper from domain object to DB row, one mapper from DB row to domain object, and keep read/write paths boring.
|
||||
- Discriminated unions: use exhaustive `switch` mappers instead of repeated inline conditionals. If insert/update share shape, build the row once and reuse it; split primary keys once for update sets.
|
||||
- Kysely rows: prefer generated `Insertable`/`Selectable` types for mapper contracts. Do not duplicate nullable-column logic inside `values(...)` and `doUpdateSet(...)`.
|
||||
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
@@ -227,7 +181,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Use `$technical-documentation` for docs writing/review. Docs change with behavior/API.
|
||||
- Codex harness upgrade (`extensions/codex/package.json` `@openai/codex`): refresh `docs/plugins/codex-harness.md` model snapshot from the new harness `model/list`.
|
||||
- Docs final answers: include relevant full `https://docs.openclaw.ai/...` URL(s). If issue/PR work too, GitHub URL last.
|
||||
- `CHANGELOG.md`: release-only. Do not edit for normal PRs, direct `main` fixes, or `ship it`; release generation owns it. Do not ask contributors/agents for changelog edits.
|
||||
- `CHANGELOG.md`: release-owned. Do not edit for normal PRs, direct `main` fixes, or `ship it`; only explicit release/changelog generation may rewrite it. Do not ask contributors/agents for changelog edits.
|
||||
- User-facing `fix`/`feat`/`perf`: put release-note context in PR body, squash message, or direct commit: behavior, surface, issue/PR refs, credited human author/reporter.
|
||||
- Release generation: derive `CHANGELOG.md` from merged PRs + all direct `main` commits. Entries: active `### Changes`/`### Fixes`, single-line, thank credited humans; never thank bots/forbidden handles: `@openclaw`, `@clawsweeper`, `@codex`, `@steipete`.
|
||||
|
||||
@@ -235,12 +189,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
- Commit via `scripts/committer "<msg>" <file...>`; stage intended files only.
|
||||
- Commits: conventional-ish, concise, grouped.
|
||||
- No manual stash/autostash unless explicit. Branch switches ok when useful; no new worktrees unless requested.
|
||||
- No manual stash/autostash unless explicit. No branch/worktree changes unless requested.
|
||||
- `main`: no merge commits; rebase on latest `origin/main` before push. After one green run plus clean rebase sanity, do not chase moving `main` with repeated full gates.
|
||||
- User says `commit`: your changes only. `commit all`: all changes in grouped chunks. `push`: may `git pull --rebase` first.
|
||||
- User says `ship it`: commit intended changes, pull --rebase, push.
|
||||
- Do not delete/rename unexpected files; ask if blocking, else ignore.
|
||||
- Bulk PR close/reopen >50: ask with count/scope.
|
||||
- Bulk PR close/reopen >5: ask with count/scope.
|
||||
|
||||
## Security / Release
|
||||
|
||||
@@ -265,7 +219,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Version bump surfaces live in `$release-openclaw-maintainer`.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- Before sharing WebVNC links, use Crabbox screenshot first; verify real app/path works and target UI is not broken.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.
|
||||
|
||||
165
CHANGELOG.md
165
CHANGELOG.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.2
|
||||
## 2026.5.30
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -10,13 +10,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
|
||||
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
|
||||
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, trace first-output latency, and expose calmer composer controls. (#88772, #88825, #88998) Thanks @vincentkoc.
|
||||
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
|
||||
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
|
||||
- Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, and rollback snapshots so failures report bounded proof instead of stalling.
|
||||
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -25,102 +20,24 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
|
||||
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
|
||||
- Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the `skill_workshop` agent tool. Thanks @shakkernerd.
|
||||
- Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.
|
||||
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
|
||||
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
|
||||
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
|
||||
- iOS: support native iPad display layouts.
|
||||
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
|
||||
- Workboard: wire task-backed board runs and show task comments in the edit modal.
|
||||
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
|
||||
- Code mode: add MCP API files and docs for code-mode integrations.
|
||||
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
|
||||
- Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.
|
||||
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
|
||||
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
|
||||
- Providers: add MiniMax M3 model support. (#88860)
|
||||
- Doctor: add disk space health checks and stabilize post-upgrade JSON probes.
|
||||
- Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)
|
||||
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Canvas: restore A2UI Google, X, and legacy Granola compatibility image assets in the bundled host payload.
|
||||
- Agents/providers: avoid loading owner plugin runtimes for explicitly configured custom provider models during OpenAI-compatible transport setup.
|
||||
- Tooling: fail Codex app-server protocol generation before invoking Cargo when local disk headroom is too low.
|
||||
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
|
||||
- Release/CI/E2E: reset shared Crabbox pnpm hydrate state before installs so stale `/var/tmp` stores cannot leave `pnpm install` spinning after completion.
|
||||
- Release/CI/E2E: print heartbeat progress during centralized Docker builds while keeping successful build logs quiet.
|
||||
- Release/CI/E2E: avoid heartbeat-tail delays in Docker E2E log wrappers while reporting captured log bytes during long runs.
|
||||
- Release/CI/E2E: keep release user-journey logs and temporary plugin fixtures under per-run scratch roots so parallel runs cannot collide or leak artifacts.
|
||||
- Release/CI/E2E: bound release candidate GitHub API calls so stalled network requests cannot wedge workflow and artifact polling.
|
||||
- Release/CI/E2E: bound Discord smoke API calls in cross-OS release checks so host-side round trips cannot hang on stalled fetches.
|
||||
- Release/CI/E2E: bound RPC RTT gateway readiness probes so a half-open local HTTP response cannot stall cleanup past the readiness deadline.
|
||||
- Release/CI/E2E: stop RPC RTT gateway process groups so pnpm wrapper children cannot survive measurement cleanup.
|
||||
- Release/CI/E2E: fail the kitchen-sink RPC walk when command RSS sampling captures no process samples.
|
||||
- Release/CI/E2E: fail kitchen-sink RPC commands that exit cleanly only after their timeout expires.
|
||||
- Release/CI/E2E: force-stop memory/fd repro gateway children that survive listener cleanup.
|
||||
- Release/CI/E2E: remove fallback ClawHub skill-install home directories when proof runs fail.
|
||||
- Release/CI/E2E: let plugin lifecycle measurement wrappers exit promptly after external shutdown while preserving descendant cleanup.
|
||||
- Gateway: cancel client stop fallback termination when the socket closes normally during shutdown.
|
||||
- Installers: fail the PowerShell installer when interactive onboarding exits non-zero.
|
||||
- Scripts/UI: stop descendant processes from wrapped non-interactive commands when `run-with-env` receives shutdown signals.
|
||||
- Release/CI/E2E: write multi-node update Docker artifacts to unique per-run directories by default so parallel runs cannot overwrite evidence.
|
||||
- Release/CI/E2E: write package Telegram Docker artifacts to unique per-run directories by default so parallel live/RTT runs cannot overwrite evidence.
|
||||
- Release/CI/E2E: keep plugin lifecycle matrix resource artifacts under a unique per-run scratch root so parallel runs cannot overwrite tarballs or inspect output.
|
||||
- Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through.
|
||||
- Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open.
|
||||
- Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup.
|
||||
- Release/CI/E2E: exit Telegram credential proof wrappers promptly after forwarded shutdown signals while keeping the descendant force-kill guard armed.
|
||||
- Release/CI/E2E: reject oversized ClickClack fixture request bodies before release journey smokes can accumulate unbounded payloads.
|
||||
- Release/CI/E2E: reject oversized OpenAI image-auth mock request bodies before Docker proof runs can accumulate unbounded payloads.
|
||||
- Release/CI/E2E: require the Kitchen Sink RPC walk to prove every expected plugin tool is cataloged and effective before invoking tool fixtures.
|
||||
- Release/CI/E2E: stop tracked Docker build commands when centralized build wrappers receive shutdown signals.
|
||||
- Release/CI/E2E: cover MCP channel pairing reconnects by asserting the same temporary client state is reused across reconnects.
|
||||
- Release/CI/E2E: require QA channel baseline and reconnect scenarios to assert their scenario markers instead of accepting any outbound reply.
|
||||
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
|
||||
- Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind.
|
||||
- Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue.
|
||||
- Release/CI/E2E: retry generated temp-state cleanup after removal failures and route plugin lifecycle measurement edits to their owner tests.
|
||||
- Release/CI/E2E: close parent gateway log handles after spawning RPC RTT probes so repeated measurements do not leak file descriptors.
|
||||
- Release/CI/E2E: fail RPC RTT probes when temporary state cleanup fails instead of hiding leftover scratch directories.
|
||||
- Release/CI/E2E: fail Kitchen Sink RPC walks when temporary state cleanup still fails after retries instead of silently preserving scratch roots.
|
||||
- Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold.
|
||||
- Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`.
|
||||
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
|
||||
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
|
||||
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
|
||||
- Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.
|
||||
- Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.
|
||||
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
|
||||
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
|
||||
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
|
||||
- Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.
|
||||
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
|
||||
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
|
||||
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
|
||||
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
|
||||
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex `lastGood` auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.
|
||||
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
|
||||
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
|
||||
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
|
||||
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
|
||||
- Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.
|
||||
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
|
||||
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
|
||||
- Memory: serialize QMD update/embed writes per store, preserve phase signals on read errors, harden envelope metadata sanitization, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931) Thanks @openperf and @amittell.
|
||||
- Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
|
||||
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
|
||||
@@ -128,20 +45,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.
|
||||
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
|
||||
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
|
||||
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
|
||||
- Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.
|
||||
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
|
||||
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
@@ -500,21 +410,76 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Backup/doctor: treat missing configured plugin load paths as warnings so stale local plugin installs do not block backup planning or state import.
|
||||
- Doctor/migration: merge legacy transcript JSONL imports instead of replacing SQLite rows, quarantine headerless transcript artifacts, and make warning-status migrations exit nonzero while pre-migration backups avoid workspace archives.
|
||||
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
|
||||
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
|
||||
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
|
||||
- Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst.
|
||||
- Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen.
|
||||
- Discord/voice: serialize overlapping voice joins, retry aborted startup readiness within the configured timeout, upgrade meeting-notes-only sessions to realtime when the normal follow join arrives, detach promoted meeting-notes ownership without leaving voice, and include `OpenClaw` in default realtime wake names.
|
||||
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
|
||||
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
|
||||
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.
|
||||
- Discord/voice: recover stale realtime playback state when Discord stream-close/player-idle events do not arrive, and keep generated runtime plugin aliases available after postbuild rewrites.
|
||||
- Discord/voice: keep realtime playback running when meeting notes attaches to an existing voice session or a realtime consult starts, and route realtime user transcripts into meeting notes.
|
||||
- Config/secrets: preflight active runtime SecretRefs before root and include config writes persist, and roll back unchanged file/env state when post-write refresh fails. Fixes #46531. (#84454) Thanks @samzong.
|
||||
- CLI/models: preserve SecretRef-backed custom provider `apiKey` markers when `models status` regenerates `models.json`, avoiding resolved plaintext secrets on disk. Fixes #84632. (#84658) Thanks @NianJiuZst.
|
||||
- WhatsApp/auto-reply: deliver deferred media replies through the foreground reply fence so overlapping no-reply turns no longer hide already visible responses. (#85517) Thanks @cavit99.
|
||||
- Sessions/security: replace agent-to-agent wildcard allowlist regexes with a precompiled linear matcher so cross-agent access checks avoid backtracking-prone patterns. (#85849) Thanks @SebTardif.
|
||||
- WebChat: keep the run-complete indicator in progress until deferred history replay renders the assistant reply, so Done no longer appears before response text. (#85374) Thanks @neeravmakwana.
|
||||
- Agents/tools: give timed-out or cancelled process trees a bounded SIGTERM cleanup window before SIGKILL while preserving tree-aware cancellation. Fixes #66399. (#85865) Thanks @IWhatsskill.
|
||||
- Agents/subagents: treat aborted subagent stop reasons as killed terminal failures so parent sessions get error announcements instead of silent success. Fixes #72293. (#85860) Thanks @IWhatsskill.
|
||||
- Agents/providers: clamp proxy-like OpenAI Chat Completions output caps against the final request payload so strict local/API-compatible servers no longer reject prompts that already consume part of the context window. Fixes #83086. (#85889) Thanks @rendrag-git.
|
||||
- Agents/compaction: skip agent-harness preflight for provider-owned CLI runtime sessions so over-threshold Claude CLI sessions continue through normal compaction instead of failing on a missing harness. Fixes #84857. (#84878) Thanks @zhangguiping-xydt.
|
||||
- Codex/app-server: keep successful native hook relays available through a short post-turn grace window so late Codex hook subprocesses can finish policy enforcement without clearing a replacement relay. (#83987) Thanks @Kaspre.
|
||||
- Control UI/config: save form-mode edits from the source config snapshot so runtime-only provider defaults like empty `models.providers.<id>.baseUrl` are not written back and rejected. Fixes #85831. Thanks @garyd9.
|
||||
- Browser/existing-session: launch Chrome DevTools MCP with usage statistics disabled by default so its telemetry watchdog stays off unless an operator explicitly opts in. (#85886) Thanks @rohitjavvadi.
|
||||
- Telegram: normalize legacy durable group retry targets before retry sends, polls, and pins so group retries keep using the real chat id. (#85656) Thanks @luoyanglang.
|
||||
- Agents/PDF: route MiniMax PDF fallback policy through plugin metadata so MiniMax uses text extraction instead of VLM image fallback. (#85590, fixes #85575) Thanks @neeravmakwana.
|
||||
- CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable.
|
||||
- Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m.
|
||||
- Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman.
|
||||
- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828.
|
||||
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
|
||||
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
|
||||
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
|
||||
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
|
||||
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
|
||||
- Gateway: stop sending duplicate message-phase `sessions.changed` websocket events after displayable `session.message` transcript updates. (#84834)
|
||||
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
|
||||
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
|
||||
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
|
||||
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
|
||||
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
|
||||
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
|
||||
- Sandbox: keep workspace skill mounts read-only for remote container-cwd file operations and reject symlinked skill roots before creating protected overlays. (#85591) Thanks @jason-allen-oneal.
|
||||
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
|
||||
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
|
||||
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
|
||||
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
|
||||
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
|
||||
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
|
||||
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
|
||||
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
|
||||
- Scripts/Windows: run the Z.AI fallback repro through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
|
||||
- Codex/Windows: run app-server protocol formatting through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
|
||||
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
|
||||
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
|
||||
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
|
||||
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
|
||||
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
|
||||
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
|
||||
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
|
||||
- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
|
||||
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
|
||||
- Plugins/Gateway: treat non-empty return values from plugin gateway method handlers as successful responses so `openclaw gateway call` no longer times out after completed plugin work. Fixes #59470. Thanks @HTMG23.
|
||||
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
|
||||
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
|
||||
- Update: keep the detached gateway restart handoff best-effort when the restart script process cannot be spawned. (#83892) Thanks @davinci282828.
|
||||
- Windows/config: skip POSIX login-shell env fallback on native Windows so startup no longer warns about missing `/bin/sh`. Fixes #84795. Thanks @JIRBOY.
|
||||
- Telegram: persist the prompt-context message cache through plugin state and record bot-authored replies after sends and draft streaming so later turns can include prior assistant replies without relying on the JSON sidecar. (#85231) Thanks @keshavbotagent.
|
||||
- Agents/subagents: keep Codex persona and user workspace files turn-scoped so native Codex subagents inherit only shared tool guidance by default. (#85811) Thanks @lastguru-net.
|
||||
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
|
||||
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
|
||||
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
|
||||
@@ -523,6 +488,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
|
||||
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
|
||||
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
|
||||
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
|
||||
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
|
||||
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
|
||||
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
|
||||
@@ -534,11 +500,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
|
||||
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
|
||||
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
|
||||
- Control UI/chat: keep light-mode model, thinking, config, and agents select arrows visible without tiling background icons. Fixes #85713. Thanks @Linux2010.
|
||||
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
|
||||
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
|
||||
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
|
||||
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
|
||||
- Gateway: defer channel account startup work until HTTP readiness and remove startup model prewarm, avoiding startup event-loop stalls and timer-delay warnings.
|
||||
- Models/perf: reuse plugin metadata during models.json planning, keep bundled catalog augmentation manifest/static, and use static provider catalogs for metadata-only startup discovery so provider model normalization, auth discovery, and Gateway startup metadata do not reload broad plugin runtimes.
|
||||
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
|
||||
- Backup/doctor: treat missing configured plugin load paths as warnings so stale local plugin installs do not block backup planning or state import.
|
||||
- Doctor/migration: merge legacy transcript JSONL imports instead of replacing SQLite rows, quarantine headerless transcript artifacts, and make warning-status migrations exit nonzero while pre-migration backups avoid workspace archives.
|
||||
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
|
||||
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
|
||||
- Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.
|
||||
@@ -714,7 +685,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
|
||||
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
|
||||
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.
|
||||
- Agents/auth profiles: replace the bare `No available auth profile for <provider> (all in cooldown or unavailable)` TUI error with plain-language copy that explains what happened in user terms (sign-in expired, provider asking us to slow down, billing issue on the account, etc.) and suggests the matching `openclaw models auth login --provider <provider>` recovery command for sign-in and billing causes, while falling back to the underlying provider error for cases without a clear recovery path. Thanks @romneyda.
|
||||
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
|
||||
|
||||
## 2026.5.20
|
||||
@@ -1457,6 +1427,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Voice Call/Telnyx: add realtime media-streaming call support for conversational voice calls. (#81024) Thanks @dynamite-bud.
|
||||
- Dependencies: add release dependency evidence reports, npm advisory gating, and PR dependency-change awareness so maintainers can review dependency risk before and during releases. Thanks @joshavant.
|
||||
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
|
||||
- Cron/state: store runtime schedule state and run history in the shared SQLite state database; `openclaw doctor --fix` imports legacy `jobs-state.json` and `cron/runs/*.jsonl` files.
|
||||
- Gateway/state: store device identity/auth, bootstrap tokens, device and node pairing ledgers, channel pairing requests/allowlists, inferred commitments, subagent run records, TUI restore pointers, auth routing state, OpenRouter model cache, web push subscriptions/VAPID keys, APNs registrations, and update-check state in the shared SQLite state database; `openclaw doctor --fix` imports and removes the legacy JSON files.
|
||||
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
|
||||
- Codex: add node-backed Codex CLI session listing and binding so an OpenClaw conversation can continue an existing Codex CLI session running on a paired node.
|
||||
|
||||
@@ -1636,6 +1608,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Subagents/maintenance: preserve pending subagent registry sessions during session-store cleanup, pruning, and disk-budget enforcement so in-flight subagent runs are not deleted by background maintenance before they complete. (#81498) Thanks @ai-hpc.
|
||||
- Control UI/chat: reconcile terminal and reconnect run cleanup with cached session activity, stale compaction/fallback indicators, and a compact composer run-status chip so completed or interrupted turns do not leave Stop active. Fixes #76874 and #64220; refs #71630. Thanks @BunsDev.
|
||||
- Maintainer tooling: clarify which pnpm test/check commands are safe locally versus inside Codex worktrees, routing linked-worktree gates through node wrappers and Crabbox/Testbox.
|
||||
- Gateway/sessions: remove the automatic cron session reaper and retired `cron.sessionRetention`; session rows are retained for explicit reset/delete flows while cron run-log pruning remains under `cron.runLog`.
|
||||
- Auto-reply: preserve same-key ordering when debounced inbound work falls back to immediate flushes, so follow-up turns cannot overtake an active buffered flush.
|
||||
- Telegram/WhatsApp: keep Telegram same-chat replies ordered behind active no-delay turns without blocking WhatsApp follow-up message dispatch.
|
||||
- Codex migration: avoid duplicate cached plugin bundle warnings when app-server plugin inventory is available.
|
||||
@@ -1834,7 +1807,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip.
|
||||
- Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11.
|
||||
- Agents/harness: skip tool-result middleware validation when no handler is registered, and sanitize incoming tool result `details` (functions, symbols, bigints, cycles, oversized payloads) before middleware sees them. Tool emitters legitimately produce raw dependency payloads on `details`, and the harness owes any registered middleware a JSON-safe view of that payload; otherwise a no-op middleware (e.g. bundled `tokenjuice` on the `pi` runtime) causes the validator to reject every tool result and silently substitute a failure sentinel, dropping outbound Discord messages, exec output, cron results, and any other tool whose payload carries non-serializable values. Thanks @solomonneas.
|
||||
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
|
||||
- Runtime/install: raise the supported Node 22 floor to `22.19+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
|
||||
- Discord/voice: make duplicate same-guild auto-join entries resolve to the last configured channel so moving an agent between voice channels does not keep joining the stale channel.
|
||||
- Discord/voice: add realtime `/vc` modes so Discord voice channels can run as STT/TTS, a realtime talk buffer with the OpenClaw agent brain, or a bidi realtime session with `openclaw_agent_consult`.
|
||||
- Discord/voice: add bounded realtime gateway logs for voice channel joins, realtime model/voice selection, transcripts, consult routing/answers, and playback start, allow OpenAI realtime Discord sessions to disable input-triggered response interruption for echo-heavy rooms while keeping explicit Discord barge-in available for new and already-active speakers, and allow voice turns to target an existing Discord channel agent session.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
# Build stages use full bookworm; the runtime image is always bookworm-slim.
|
||||
ARG OPENCLAW_EXTENSIONS=""
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
# Keep in sync with .github/actions/setup-node-env/action.yml bun-version.
|
||||
# To update: docker buildx imagetools inspect oven/bun:<version> and use the manifest-list digest.
|
||||
ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e"
|
||||
|
||||
@@ -218,7 +218,6 @@ Current OpenClaw Android implication:
|
||||
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026060201
|
||||
versionName = "2026.6.2"
|
||||
versionCode = 2026053001
|
||||
versionName = "2026.5.30"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -2,18 +2,10 @@ package ai.openclaw.app
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
/** Android Assistant entry point used by manifest-declared app actions. */
|
||||
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
|
||||
|
||||
/** Debug action that opens the Voice tab directly for Android E2E automation. */
|
||||
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
|
||||
|
||||
/** Intent extra that carries an optional assistant prompt for app actions. */
|
||||
const val extraAssistantPrompt = "prompt"
|
||||
|
||||
/**
|
||||
* Top-level home destinations that external actions may request.
|
||||
*/
|
||||
enum class HomeDestination {
|
||||
Connect,
|
||||
Chat,
|
||||
@@ -22,30 +14,20 @@ enum class HomeDestination {
|
||||
Settings,
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized launch request from Android Assistant or explicit app actions.
|
||||
*/
|
||||
data class AssistantLaunchRequest(
|
||||
val source: String,
|
||||
val prompt: String?,
|
||||
val autoSend: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses app-owned navigation actions that should open a specific home tab.
|
||||
*/
|
||||
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
|
||||
val action = intent?.action ?: return null
|
||||
return when {
|
||||
// Debug-only shortcut keeps E2E navigation out of release builds.
|
||||
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse external assistant entry points without starting any UI side effects.
|
||||
*/
|
||||
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
|
||||
val action = intent?.action ?: return null
|
||||
return when (action) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/** Camera HUD state categories shown over the Android UI during capture. */
|
||||
enum class CameraHudKind {
|
||||
Photo,
|
||||
Recording,
|
||||
@@ -8,7 +7,6 @@ enum class CameraHudKind {
|
||||
Error,
|
||||
}
|
||||
|
||||
/** One-shot camera HUD message keyed by token so repeated text still replays. */
|
||||
data class CameraHudState(
|
||||
val token: Long,
|
||||
val kind: CameraHudKind,
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
||||
object DeviceNames {
|
||||
/** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */
|
||||
fun bestDefaultNodeName(context: Context): String {
|
||||
val deviceName =
|
||||
runCatching {
|
||||
@@ -16,8 +15,6 @@ object DeviceNames {
|
||||
|
||||
if (deviceName.isNotEmpty()) return deviceName
|
||||
|
||||
// Manufacturer/model are best-effort platform fields; keep the final
|
||||
// fallback stable so stored default names do not become blank.
|
||||
val model =
|
||||
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
|
||||
.joinToString(" ")
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/**
|
||||
* Persisted location capture mode advertised to the gateway.
|
||||
*/
|
||||
enum class LocationMode(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -11,10 +8,8 @@ enum class LocationMode(
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Parses persisted location mode text while migrating old always-on configs to while-using. */
|
||||
fun fromRawValue(raw: String?): LocationMode {
|
||||
val normalized = raw?.trim()?.lowercase()
|
||||
// Older configs used "always"; Android node currently exposes while-using location only.
|
||||
if (normalized == "always") return WhileUsing
|
||||
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
@@ -46,7 +43,6 @@ class MainActivity : ComponentActivity() {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.runtimeInitialized.collect { ready ->
|
||||
if (!ready || didAttachRuntimeUi) return@collect
|
||||
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
|
||||
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
didAttachRuntimeUi = true
|
||||
if (!didStartNodeService) {
|
||||
@@ -82,9 +78,6 @@ class MainActivity : ComponentActivity() {
|
||||
handleAssistantIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
|
||||
*/
|
||||
private fun handleAssistantIntent(intent: android.content.Intent?) {
|
||||
parseHomeDestinationIntent(intent)?.let { destination ->
|
||||
viewModel.requestHomeDestination(destination)
|
||||
|
||||
@@ -22,9 +22,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModel(
|
||||
app: Application,
|
||||
@@ -42,9 +39,6 @@ class MainViewModel(
|
||||
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
|
||||
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
|
||||
|
||||
/**
|
||||
* Lazily starts NodeRuntime and preserves the current foreground bit across startup.
|
||||
*/
|
||||
private fun ensureRuntime(): NodeRuntime {
|
||||
runtimeRef.value?.let { return it }
|
||||
val runtime = nodeApp.ensureRuntime()
|
||||
@@ -53,9 +47,6 @@ class MainViewModel(
|
||||
return runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
|
||||
*/
|
||||
private fun <T> runtimeState(
|
||||
initial: T,
|
||||
selector: (NodeRuntime) -> StateFlow<T>,
|
||||
@@ -148,7 +139,6 @@ class MainViewModel(
|
||||
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
|
||||
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
|
||||
@@ -195,9 +185,6 @@ class MainViewModel(
|
||||
val sms: SmsManager
|
||||
get() = ensureRuntime().sms
|
||||
|
||||
/**
|
||||
* Attaches Activity-owned permission and lifecycle seams after runtime initialization.
|
||||
*/
|
||||
fun attachRuntimeUi(
|
||||
owner: LifecycleOwner,
|
||||
permissionRequester: PermissionRequester,
|
||||
@@ -208,9 +195,6 @@ class MainViewModel(
|
||||
runtime.sms.attachPermissionRequester(permissionRequester)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts runtime on foreground entry only after onboarding has completed.
|
||||
*/
|
||||
fun setForeground(value: Boolean) {
|
||||
foreground = value
|
||||
val runtime =
|
||||
@@ -270,12 +254,10 @@ class MainViewModel(
|
||||
prefs.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
/** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
|
||||
fun resetGatewaySetupAuth() {
|
||||
ensureRuntime().resetGatewaySetupAuth()
|
||||
}
|
||||
|
||||
/** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
|
||||
fun setOnboardingCompleted(value: Boolean) {
|
||||
if (value) {
|
||||
ensureRuntime()
|
||||
@@ -283,7 +265,6 @@ class MainViewModel(
|
||||
prefs.setOnboardingCompleted(value)
|
||||
}
|
||||
|
||||
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
|
||||
fun pairNewGateway() {
|
||||
runtimeRef.value?.disconnect()
|
||||
resetGatewaySetupAuth()
|
||||
@@ -291,7 +272,6 @@ class MainViewModel(
|
||||
prefs.setOnboardingCompleted(false)
|
||||
}
|
||||
|
||||
/** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
|
||||
fun clearGatewaySetupStartRequest() {
|
||||
_startOnboardingAtGatewaySetup.value = false
|
||||
}
|
||||
@@ -300,10 +280,6 @@ class MainViewModel(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
ensureRuntime().setInstalledAppsSharingEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
ensureRuntime().setNotificationForwardingEnabled(value)
|
||||
}
|
||||
@@ -339,7 +315,6 @@ class MainViewModel(
|
||||
ensureRuntime().setVoiceScreenActive(active)
|
||||
}
|
||||
|
||||
/** Routes assistant intents into chat, either as a draft or queued auto-send prompt. */
|
||||
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
|
||||
_requestedHomeDestination.value = HomeDestination.Chat
|
||||
if (request.autoSend) {
|
||||
|
||||
@@ -3,17 +3,11 @@ package ai.openclaw.app
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
|
||||
/**
|
||||
* Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup.
|
||||
*/
|
||||
class NodeApp : Application() {
|
||||
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
|
||||
|
||||
@Volatile private var runtimeInstance: NodeRuntime? = null
|
||||
|
||||
/**
|
||||
* Returns the single NodeRuntime for this process, creating it on first use.
|
||||
*/
|
||||
fun ensureRuntime(): NodeRuntime {
|
||||
runtimeInstance?.let { return it }
|
||||
return synchronized(this) {
|
||||
@@ -21,9 +15,6 @@ class NodeApp : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the runtime without forcing startup, used by lifecycle probes and services.
|
||||
*/
|
||||
fun peekRuntime(): NodeRuntime? = runtimeInstance
|
||||
|
||||
override fun onCreate() {
|
||||
|
||||
@@ -19,7 +19,6 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */
|
||||
class NodeForegroundService : Service() {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var notificationJob: Job? = null
|
||||
@@ -37,8 +36,6 @@ class NodeForegroundService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
// Split connection and capture flows before combining so notification text
|
||||
// can update without restarting runtime-owned connection work.
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
@@ -184,7 +181,6 @@ class NodeForegroundService : Service() {
|
||||
private fun startForegroundWithTypes(notification: Notification) {
|
||||
val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode)
|
||||
if (didStartForeground) {
|
||||
// Re-issue startForeground when Talk mode toggles so Android sees the microphone service type.
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
|
||||
return
|
||||
}
|
||||
@@ -200,19 +196,16 @@ class NodeForegroundService : Service() {
|
||||
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
|
||||
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
|
||||
|
||||
/** Starts the persistent node foreground service from UI lifecycle code. */
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/** Requests disconnect through the service action path so notification actions and UI share behavior. */
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
/** Updates Android's foreground-service type before voice capture mode changes require microphone access. */
|
||||
fun setVoiceCaptureMode(
|
||||
context: Context,
|
||||
mode: VoiceCaptureMode,
|
||||
@@ -222,7 +215,6 @@ class NodeForegroundService : Service() {
|
||||
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
|
||||
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
|
||||
if (mode == VoiceCaptureMode.TalkMode) {
|
||||
// Microphone foreground service type must be declared before Talk capture starts.
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
@@ -231,9 +223,6 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground-service type mask required by Android for the current voice capture mode.
|
||||
*/
|
||||
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
|
||||
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
return if (mode == VoiceCaptureMode.TalkMode) {
|
||||
@@ -243,9 +232,6 @@ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact notification suffix for voice state; kept pure for service-notification tests.
|
||||
*/
|
||||
internal fun voiceNotificationSuffix(
|
||||
mode: VoiceCaptureMode,
|
||||
manualMicEnabled: Boolean,
|
||||
@@ -274,7 +260,6 @@ private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
|
||||
it.name == this
|
||||
} ?: VoiceCaptureMode.Off
|
||||
|
||||
/** Connection fields that drive foreground notification title/body text. */
|
||||
private data class VoiceNotificationBase(
|
||||
val status: String,
|
||||
val server: String?,
|
||||
@@ -282,7 +267,6 @@ private data class VoiceNotificationBase(
|
||||
val mode: VoiceCaptureMode,
|
||||
)
|
||||
|
||||
/** Voice capture fields that affect foreground-service type and suffix. */
|
||||
private data class VoiceNotificationCapture(
|
||||
val micEnabled: Boolean,
|
||||
val micListening: Boolean,
|
||||
@@ -290,7 +274,6 @@ private data class VoiceNotificationCapture(
|
||||
val talkSpeaking: Boolean,
|
||||
)
|
||||
|
||||
/** Aggregated notification state from runtime flows. */
|
||||
private data class VoiceNotificationState(
|
||||
val base: VoiceNotificationBase,
|
||||
val capture: VoiceNotificationCapture,
|
||||
|
||||
@@ -6,6 +6,7 @@ import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceAuthTokenStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayDiscovery
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
@@ -75,17 +76,12 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
|
||||
*/
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
|
||||
private val deviceAuthStore: DeviceAuthTokenStore = DeviceAuthStore(context.applicationContext),
|
||||
) {
|
||||
/**
|
||||
* Authentication material supplied by setup/manual connect flows before gateway session routing.
|
||||
*/
|
||||
data class GatewayConnectAuth(
|
||||
val token: String?,
|
||||
val bootstrapToken: String?,
|
||||
@@ -94,7 +90,6 @@ class NodeRuntime(
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
@@ -115,7 +110,6 @@ class NodeRuntime(
|
||||
|
||||
private val cameraHandler: CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
@@ -125,7 +119,6 @@ class NodeRuntime(
|
||||
|
||||
private val debugHandler: DebugHandler =
|
||||
DebugHandler(
|
||||
appContext = appContext,
|
||||
identityStore = identityStore,
|
||||
)
|
||||
|
||||
@@ -207,7 +200,6 @@ class NodeRuntime(
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
|
||||
@@ -246,7 +238,6 @@ class NodeRuntime(
|
||||
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
@@ -259,9 +250,6 @@ class NodeRuntime(
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Pending TLS trust decision when a gateway certificate is new or has changed.
|
||||
*/
|
||||
data class GatewayTrustPrompt(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
@@ -293,9 +281,6 @@ class NodeRuntime(
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
private val connectAttemptSeq = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* Builds the node-owned session key from stable device identity plus optional active agent.
|
||||
*/
|
||||
private fun resolveNodeMainSessionKey(agentId: String? = null): String {
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
return buildNodeMainSessionKey(deviceId, agentId)
|
||||
@@ -855,7 +840,6 @@ class NodeRuntime(
|
||||
|
||||
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
|
||||
|
||||
/** Clears setup credentials plus paired device tokens for both Android gateway roles. */
|
||||
fun resetGatewaySetupAuth() {
|
||||
prefs.clearGatewaySetupAuth()
|
||||
val deviceId = identityStore.loadOrCreate().deviceId
|
||||
@@ -863,12 +847,10 @@ class NodeRuntime(
|
||||
deviceAuthStore.clearToken(deviceId, "operator")
|
||||
}
|
||||
|
||||
/** Persists onboarding state; callers decide whether runtime startup is needed first. */
|
||||
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
||||
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
|
||||
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
|
||||
prefs.notificationForwardingMode
|
||||
@@ -934,7 +916,6 @@ class NodeRuntime(
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
/** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
if (value) {
|
||||
@@ -1024,8 +1005,6 @@ class NodeRuntime(
|
||||
if (didAutoConnect) return
|
||||
if (_isConnected.value) return
|
||||
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||
// Only attempt the stored preferred gateway once per runtime lifetime; users
|
||||
// can still reconnect explicitly from the UI after a failed auto attempt.
|
||||
didAutoConnect = true
|
||||
connect(endpoint)
|
||||
}
|
||||
@@ -1080,12 +1059,6 @@ class NodeRuntime(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
if (prefs.installedAppsSharingEnabled.value == value) return
|
||||
prefs.setInstalledAppsSharingEnabled(value)
|
||||
refreshNodeSurfaceAfterSharingChange()
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
prefs.setNotificationForwardingEnabled(value)
|
||||
}
|
||||
@@ -1423,11 +1396,6 @@ class NodeRuntime(
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun refreshNodeSurfaceAfterSharingChange() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun connectWithAuth(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
|
||||
@@ -3,7 +3,6 @@ package ai.openclaw.app
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/** Package-filter mode used before notification events are forwarded to the gateway. */
|
||||
enum class NotificationPackageFilterMode(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -12,12 +11,10 @@ enum class NotificationPackageFilterMode(
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Parses persisted filter mode text, defaulting to blocklist for safer forwarding. */
|
||||
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime policy used before forwarding notification events to a node session. */
|
||||
internal data class NotificationForwardingPolicy(
|
||||
val enabled: Boolean,
|
||||
val mode: NotificationPackageFilterMode,
|
||||
@@ -29,7 +26,6 @@ internal data class NotificationForwardingPolicy(
|
||||
val sessionKey: String?,
|
||||
)
|
||||
|
||||
/** Applies the operator-configured package allow/block list after trimming input. */
|
||||
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
|
||||
val normalized = packageName.trim()
|
||||
if (normalized.isEmpty()) {
|
||||
@@ -41,7 +37,6 @@ internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Bo
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true for both same-day and overnight quiet-hour windows. */
|
||||
internal fun NotificationForwardingPolicy.isWithinQuietHours(
|
||||
nowEpochMs: Long,
|
||||
zoneId: ZoneId = ZoneId.systemDefault(),
|
||||
@@ -69,14 +64,12 @@ internal fun NotificationForwardingPolicy.isWithinQuietHours(
|
||||
|
||||
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
|
||||
|
||||
/** Normalizes persisted or user-entered local times to strict HH:mm form. */
|
||||
internal fun normalizeLocalHourMinute(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
|
||||
return "${match.groupValues[1]}:${match.groupValues[2]}"
|
||||
}
|
||||
|
||||
/** Converts strict local HH:mm text to minutes since midnight for window checks. */
|
||||
internal fun parseLocalHourMinute(raw: String): Int? {
|
||||
val normalized = normalizeLocalHourMinute(raw) ?: return null
|
||||
val parts = normalized.split(':')
|
||||
@@ -85,13 +78,11 @@ internal fun parseLocalHourMinute(raw: String): Int? {
|
||||
return hour * 60 + minute
|
||||
}
|
||||
|
||||
/** Fixed-window limiter that bounds notification bursts per wall-clock minute. */
|
||||
internal class NotificationBurstLimiter {
|
||||
private val lock = Any()
|
||||
private var windowStartMs: Long = -1L
|
||||
private var eventsInWindow: Int = 0
|
||||
|
||||
/** Returns true when the current minute bucket still has forwarding capacity. */
|
||||
fun allow(
|
||||
nowEpochMs: Long,
|
||||
maxEventsPerMinute: Int,
|
||||
@@ -99,8 +90,6 @@ internal class NotificationBurstLimiter {
|
||||
if (maxEventsPerMinute <= 0) {
|
||||
return false
|
||||
}
|
||||
// Align all callers to the same minute bucket so concurrent notifications
|
||||
// share the quota even when they arrive with slightly different timestamps.
|
||||
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
|
||||
synchronized(lock) {
|
||||
if (currentWindow != windowStartMs) {
|
||||
|
||||
@@ -26,9 +26,6 @@ import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Serializes Android runtime-permission prompts behind coroutine-friendly request calls.
|
||||
*/
|
||||
class PermissionRequester internal constructor(
|
||||
private val activity: ComponentActivity,
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
@@ -53,12 +50,8 @@ class PermissionRequester internal constructor(
|
||||
private val mutex = Mutex()
|
||||
private val requestSlotsLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
// ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows.
|
||||
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
|
||||
|
||||
/**
|
||||
* Request missing Android runtime permissions and return the final grant state for every requested permission.
|
||||
*/
|
||||
suspend fun requestIfMissing(
|
||||
permissions: List<String>,
|
||||
timeoutMs: Long = 20_000,
|
||||
@@ -100,7 +93,6 @@ class PermissionRequester internal constructor(
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
// Late ActivityResult callbacks are ignored by completePermissionRequest.
|
||||
request.timedOut = true
|
||||
throw err
|
||||
}
|
||||
@@ -138,7 +130,6 @@ class PermissionRequester internal constructor(
|
||||
|
||||
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
|
||||
synchronized(requestSlotsLock) {
|
||||
// The outer mutex serializes normal callers; this guard catches accidental concurrent launchers in tests.
|
||||
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
|
||||
slot.request = request
|
||||
slot
|
||||
@@ -154,7 +145,6 @@ class PermissionRequester internal constructor(
|
||||
slot.request = null
|
||||
}
|
||||
} ?: return
|
||||
// Timed-out requests have already resumed callers with failure; ignore any late platform callback.
|
||||
if (request.timedOut) return
|
||||
request.deferred.complete(result)
|
||||
}
|
||||
@@ -196,7 +186,6 @@ class PermissionRequester internal constructor(
|
||||
val actualObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
|
||||
// Do not resume a destroyed Activity with a positive result.
|
||||
finish(false)
|
||||
}
|
||||
observer = actualObserver
|
||||
|
||||
@@ -15,9 +15,6 @@ import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Reactive settings facade for Android node preferences and encrypted gateway credentials.
|
||||
*/
|
||||
class SecurePrefs(
|
||||
context: Context,
|
||||
private val securePrefsOverride: SharedPreferences? = null,
|
||||
@@ -40,18 +37,14 @@ class SecurePrefs(
|
||||
private const val notificationsForwardingMaxEventsPerMinuteKey =
|
||||
"notifications.forwarding.maxEventsPerMinute"
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
|
||||
private const val voiceMicEnabledKey = "voice.micEnabled"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
|
||||
private val plainPrefs: SharedPreferences =
|
||||
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
|
||||
|
||||
// Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences.
|
||||
private val masterKey by lazy {
|
||||
MasterKey
|
||||
.Builder(appContext)
|
||||
@@ -116,10 +109,6 @@ class SecurePrefs(
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _installedAppsSharingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(installedAppsSharingEnabledKey, false))
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
|
||||
|
||||
private val _notificationForwardingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
|
||||
@@ -258,18 +247,12 @@ class SecurePrefs(
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean(installedAppsSharingEnabledKey, value) }
|
||||
_installedAppsSharingEnabled.value = value
|
||||
}
|
||||
|
||||
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
|
||||
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
|
||||
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
|
||||
|
||||
val configuredPackages = loadNotificationForwardingPackages()
|
||||
val normalizedAppPackage = appPackageName.trim()
|
||||
// Always block OpenClaw's own notifications in blocklist mode to prevent forwarding loops.
|
||||
val defaultBlockedPackages =
|
||||
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
|
||||
|
||||
@@ -328,7 +311,6 @@ class SecurePrefs(
|
||||
.toSet()
|
||||
.toList()
|
||||
.sorted()
|
||||
// Persist deterministic JSON so settings diffs and state restoration are stable.
|
||||
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
|
||||
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
|
||||
_notificationForwardingPackages.value = sanitized.toSet()
|
||||
@@ -373,7 +355,6 @@ class SecurePrefs(
|
||||
_notificationForwardingSessionKey.value = normalized
|
||||
}
|
||||
|
||||
/** Loads manual or instance-scoped gateway token material from encrypted preferences. */
|
||||
fun loadGatewayToken(): String? {
|
||||
val manual =
|
||||
_gatewayToken.value.trim().ifEmpty {
|
||||
@@ -382,19 +363,16 @@ class SecurePrefs(
|
||||
stored
|
||||
}
|
||||
if (manual.isNotEmpty()) return manual
|
||||
// Per-instance tokens keep reused Android installs from sharing stale gateway auth.
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = securePrefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Saves the paired gateway token under the current Android instance id. */
|
||||
fun saveGatewayToken(token: String) {
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
securePrefs.edit { putString(key, token.trim()) }
|
||||
}
|
||||
|
||||
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
|
||||
fun loadGatewayBootstrapToken(): String? {
|
||||
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||
val stored =
|
||||
@@ -426,11 +404,9 @@ class SecurePrefs(
|
||||
securePrefs.edit { putString(key, password.trim()) }
|
||||
}
|
||||
|
||||
/** Clears manual/setup credentials without removing persisted role-specific device tokens. */
|
||||
fun clearGatewaySetupAuth() {
|
||||
val instanceId = _instanceId.value
|
||||
securePrefs.edit {
|
||||
// Clear both current manual credentials and instance-scoped setup credentials after pairing/reset.
|
||||
remove("gateway.manual.token")
|
||||
remove("gateway.token.$instanceId")
|
||||
remove("gateway.bootstrapToken.$instanceId")
|
||||
@@ -440,13 +416,11 @@ class SecurePrefs(
|
||||
_gatewayBootstrapToken.value = ""
|
||||
}
|
||||
|
||||
/** Loads the pinned gateway TLS fingerprint for a discovered/manual stable endpoint id. */
|
||||
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||
val key = "gateway.tls.$stableId"
|
||||
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Persists the gateway TLS fingerprint captured through TOFU or explicit trust. */
|
||||
fun saveGatewayTlsFingerprint(
|
||||
stableId: String,
|
||||
fingerprint: String,
|
||||
@@ -468,6 +442,23 @@ class SecurePrefs(
|
||||
securePrefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
fun keysWithPrefix(prefix: String): Set<String> =
|
||||
securePrefs
|
||||
.all
|
||||
.keys
|
||||
.filter { it.startsWith(prefix) }
|
||||
.toSet()
|
||||
|
||||
fun removeKeysWithPrefix(prefix: String) {
|
||||
val keys = keysWithPrefix(prefix)
|
||||
if (keys.isEmpty()) return
|
||||
securePrefs.edit {
|
||||
for (key in keys) {
|
||||
remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSecurePrefs(
|
||||
context: Context,
|
||||
name: String,
|
||||
@@ -483,7 +474,6 @@ class SecurePrefs(
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
// Instance id is not secret; it scopes local credentials and survives display-name changes.
|
||||
val fresh = UUID.randomUUID().toString()
|
||||
plainPrefs.edit { putString("node.instanceId", fresh) }
|
||||
return fresh
|
||||
@@ -493,7 +483,6 @@ class SecurePrefs(
|
||||
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
|
||||
if (existing.isNotEmpty() && existing != "Android Node") return existing
|
||||
|
||||
// Replace the historical generic name with a device-specific default once.
|
||||
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
|
||||
val resolved = candidate.ifEmpty { "Android Node" }
|
||||
|
||||
@@ -501,7 +490,6 @@ class SecurePrefs(
|
||||
return resolved
|
||||
}
|
||||
|
||||
/** Persists sanitized voice wake triggers and updates the reactive settings flow. */
|
||||
fun setWakeWords(words: List<String>) {
|
||||
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
|
||||
val encoded =
|
||||
@@ -550,7 +538,7 @@ class SecurePrefs(
|
||||
val raw = plainPrefs.getString(voiceWakeModeKey, null)
|
||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||
|
||||
// Default ON (foreground) when unset, but keep "always" opt-in through explicit settings.
|
||||
// Default ON (foreground) when unset.
|
||||
if (raw.isNullOrBlank()) {
|
||||
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
|
||||
}
|
||||
@@ -562,7 +550,6 @@ class SecurePrefs(
|
||||
val raw = plainPrefs.getString(locationModeKey, "off")
|
||||
val resolved = LocationMode.fromRawValue(raw)
|
||||
if (raw?.trim()?.lowercase() == "always") {
|
||||
// Migrate old "always" configs to the current while-using contract.
|
||||
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
|
||||
}
|
||||
return resolved
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/** Normalizes blank gateway session keys to the legacy main session alias. */
|
||||
internal fun normalizeMainKey(raw: String?): String {
|
||||
val trimmed = raw?.trim()
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
|
||||
/** Accepts only gateway session keys that can represent the main chat stream. */
|
||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return false
|
||||
@@ -14,7 +12,6 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
||||
return trimmed.startsWith("agent:")
|
||||
}
|
||||
|
||||
/** Extracts the agent id from canonical agent-scoped main session keys. */
|
||||
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (!trimmed.startsWith("agent:")) return null
|
||||
@@ -25,7 +22,6 @@ internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Builds the node session key shape consumed by gateway chat and presence APIs. */
|
||||
internal fun buildNodeMainSessionKey(
|
||||
deviceId: String,
|
||||
agentId: String?,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/**
|
||||
* Persisted voice capture mode that controls foreground-service microphone requirements.
|
||||
*/
|
||||
enum class VoiceCaptureMode {
|
||||
Off,
|
||||
ManualMic,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/**
|
||||
* Persisted wake-word mode; raw values are stored in secure preferences.
|
||||
*/
|
||||
enum class VoiceWakeMode(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -12,9 +9,6 @@ enum class VoiceWakeMode(
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Invalid stored values fall back to foreground wake so hands-free behavior stays opt-in.
|
||||
*/
|
||||
fun fromRawValue(raw: String?): VoiceWakeMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/**
|
||||
* Wake-word parsing limits and sanitizers shared by settings and voice runtime paths.
|
||||
*/
|
||||
object WakeWords {
|
||||
const val maxWords: Int = 32
|
||||
const val maxWordLength: Int = 64
|
||||
|
||||
/** Splits comma-separated user input into non-empty wake-word entries. */
|
||||
fun parseCommaSeparated(input: String): List<String> = input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
/** Returns null when edited text normalizes to the current wake-word list. */
|
||||
fun parseIfChanged(
|
||||
input: String,
|
||||
current: List<String>,
|
||||
@@ -19,7 +14,6 @@ object WakeWords {
|
||||
return if (parsed == current) null else parsed
|
||||
}
|
||||
|
||||
/** Applies persisted-list bounds and falls back to defaults when all entries are empty. */
|
||||
fun sanitize(
|
||||
words: List<String>,
|
||||
defaults: List<String>,
|
||||
|
||||
@@ -61,15 +61,12 @@ class ChatController(
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
|
||||
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
// Drops stale history responses after session switches or refresh races.
|
||||
private val historyLoadGeneration = AtomicLong(0)
|
||||
|
||||
private var lastHealthPollAtMs: Long? = null
|
||||
|
||||
/** Clears transient chat state when the operator gateway session disconnects. */
|
||||
fun onDisconnected(message: String) {
|
||||
_healthOk.value = false
|
||||
_errorText.value = null
|
||||
@@ -81,7 +78,6 @@ class ChatController(
|
||||
_sessionId.value = null
|
||||
}
|
||||
|
||||
/** Loads a chat session, normalizing "main" to the current gateway-provided main session key. */
|
||||
fun load(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
val generation = beginHistoryLoad(key, clearMessages = key != _sessionKey.value)
|
||||
@@ -90,7 +86,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebinds chat to a new canonical main session key after gateway hello/agent changes. */
|
||||
fun applyMainSessionKey(mainSessionKey: String) {
|
||||
val trimmed = mainSessionKey.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
@@ -113,7 +108,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes current chat history and session list without clearing optimistic messages first. */
|
||||
fun refresh() {
|
||||
val key = normalizeRequestedSessionKey(_sessionKey.value)
|
||||
val generation = beginHistoryLoad(key, clearMessages = false)
|
||||
@@ -126,14 +120,12 @@ class ChatController(
|
||||
scope.launch { fetchSessions(limit = limit) }
|
||||
}
|
||||
|
||||
/** Persists the normalized thinking level used for subsequent chat sends. */
|
||||
fun setThinkingLevel(thinkingLevel: String) {
|
||||
val normalized = normalizeThinking(thinkingLevel)
|
||||
if (normalized == _thinkingLevel.value) return
|
||||
_thinkingLevel.value = normalized
|
||||
}
|
||||
|
||||
/** Switches to another gateway chat session and starts a fresh history load. */
|
||||
fun switchSession(sessionKey: String) {
|
||||
val key = normalizeRequestedSessionKey(sessionKey)
|
||||
if (key.isEmpty()) return
|
||||
@@ -171,7 +163,6 @@ class ChatController(
|
||||
return key
|
||||
}
|
||||
|
||||
/** Queues a chat send without waiting for gateway acceptance. */
|
||||
fun sendMessage(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
@@ -186,7 +177,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a chat message and returns once the gateway accepts or rejects the request. */
|
||||
suspend fun sendMessageAwaitAcceptance(
|
||||
message: String,
|
||||
thinkingLevel: String,
|
||||
@@ -204,7 +194,7 @@ class ChatController(
|
||||
val sessionKey = _sessionKey.value
|
||||
val thinking = normalizeThinking(thinkingLevel)
|
||||
|
||||
// Optimistic user message keeps the composer responsive while chat.send and history refresh complete.
|
||||
// Optimistic user message.
|
||||
val userContent =
|
||||
buildList {
|
||||
add(ChatMessageContent(type = "text", text = text))
|
||||
@@ -267,7 +257,6 @@ class ChatController(
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val actualRunId = parseRunId(res) ?: runId
|
||||
if (actualRunId != runId) {
|
||||
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
|
||||
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
|
||||
clearPendingRun(runId)
|
||||
armPendingRunTimeout(actualRunId)
|
||||
@@ -285,7 +274,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends best-effort abort requests for every currently pending gateway run. */
|
||||
fun abort() {
|
||||
val runIds =
|
||||
synchronized(pendingRuns) {
|
||||
@@ -308,7 +296,6 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
|
||||
fun handleGatewayEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -409,7 +396,7 @@ class ChatController(
|
||||
val state = payload["state"].asStringOrNull()
|
||||
when (state) {
|
||||
"delta" -> {
|
||||
// Only show streaming text for runs we initiated in this controller.
|
||||
// Only show streaming text for runs we initiated
|
||||
if (!isPending) return
|
||||
val text = parseAssistantDeltaText(payload)
|
||||
if (!text.isNullOrEmpty()) {
|
||||
@@ -650,9 +637,6 @@ internal fun isCurrentHistoryLoad(
|
||||
activeGeneration: Long,
|
||||
): Boolean = requestedSessionKey == currentSessionKey && requestGeneration == activeGeneration
|
||||
|
||||
/**
|
||||
* Convert gateway chat content parts into Android UI content parts.
|
||||
*/
|
||||
internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
val obj = el.asObjectOrNull() ?: return null
|
||||
return when (obj["type"].asStringOrNull() ?: "text") {
|
||||
@@ -679,9 +663,6 @@ internal data class MainSessionState(
|
||||
val appliedMainSessionKey: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Rewrite only the active "main" alias when the gateway publishes a new canonical main session key.
|
||||
*/
|
||||
internal fun applyMainSessionKey(
|
||||
currentSessionKey: String,
|
||||
appliedMainSessionKey: String,
|
||||
@@ -699,9 +680,6 @@ internal fun applyMainSessionKey(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Compose item identity stable across history refreshes by matching existing messages to incoming copies.
|
||||
*/
|
||||
internal fun reconcileMessageIds(
|
||||
previous: List<ChatMessage>,
|
||||
incoming: List<ChatMessage>,
|
||||
@@ -751,9 +729,6 @@ internal fun mergeOptimisticMessages(
|
||||
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
|
||||
*/
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
/**
|
||||
* Chat transcript item as delivered by gateway chat history and live chat events.
|
||||
*/
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val role: String,
|
||||
@@ -10,9 +7,6 @@ data class ChatMessage(
|
||||
val timestampMs: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One content part in a chat message; binary parts carry base64 plus their MIME metadata.
|
||||
*/
|
||||
data class ChatMessageContent(
|
||||
val type: String = "text",
|
||||
val text: String? = null,
|
||||
@@ -21,9 +15,6 @@ data class ChatMessageContent(
|
||||
val base64: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Tool call placeholder shown while a gateway run is still streaming.
|
||||
*/
|
||||
data class ChatPendingToolCall(
|
||||
val toolCallId: String,
|
||||
val name: String,
|
||||
@@ -32,18 +23,12 @@ data class ChatPendingToolCall(
|
||||
val isError: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Stable session selector row; [key] is the gateway session key used in chat requests.
|
||||
*/
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Snapshot of one chat session, including optional thinking level selected on the gateway.
|
||||
*/
|
||||
data class ChatHistory(
|
||||
val sessionKey: String,
|
||||
val sessionId: String?,
|
||||
@@ -51,9 +36,6 @@ data class ChatHistory(
|
||||
val messages: List<ChatMessage>,
|
||||
)
|
||||
|
||||
/**
|
||||
* User-selected attachment payload sent to the gateway as inline base64.
|
||||
*/
|
||||
data class OutgoingAttachment(
|
||||
val type: String,
|
||||
val mimeType: String,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
/**
|
||||
* Decoder for Bonjour DNS-SD service names returned with decimal byte escapes.
|
||||
*/
|
||||
object BonjourEscapes {
|
||||
/** Decodes Bonjour DNS-SD decimal escapes while preserving ordinary UTF-8. */
|
||||
fun decode(input: String): String {
|
||||
if (input.isEmpty()) return input
|
||||
|
||||
@@ -19,7 +15,6 @@ object BonjourEscapes {
|
||||
val value =
|
||||
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
|
||||
if (value in 0..255) {
|
||||
// Bonjour escape bytes are decimal octets, not Unicode code points.
|
||||
bytes.add(value.toByte())
|
||||
i += 4
|
||||
continue
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
/**
|
||||
* Canonical device-auth payload builder shared with gateway verification rules.
|
||||
*/
|
||||
internal object DeviceAuthPayload {
|
||||
/** Builds the canonical v3 auth string signed by device registration flows. */
|
||||
fun buildV3(
|
||||
deviceId: String,
|
||||
clientId: String,
|
||||
@@ -36,7 +32,6 @@ internal object DeviceAuthPayload {
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
/** Normalizes signed metadata fields without locale-sensitive lowercasing. */
|
||||
internal fun normalizeMetadataField(value: String?): String {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Stored gateway device-token material scoped by device id and role. */
|
||||
data class DeviceAuthEntry(
|
||||
val token: String,
|
||||
val role: String,
|
||||
@@ -19,21 +20,21 @@ private data class PersistedDeviceAuthMetadata(
|
||||
val updatedAtMs: Long = 0L,
|
||||
)
|
||||
|
||||
/** Persistence interface used by gateway pairing/session code for role tokens. */
|
||||
private const val deviceAuthTokenPrefix = "gateway.deviceToken."
|
||||
private const val deviceAuthMetadataPrefix = "gateway.deviceTokenMeta."
|
||||
private const val sqliteSecurePrefsTokenMarker = "__openclaw_secure_prefs__"
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
/** Loads the stored token plus metadata for one device/role pair. */
|
||||
fun loadEntry(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): DeviceAuthEntry?
|
||||
|
||||
/** Loads only the bearer token when callers do not need scope metadata. */
|
||||
fun loadToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String? = loadEntry(deviceId, role)?.token
|
||||
|
||||
/** Persists a role token and deterministic scope metadata under normalized keys. */
|
||||
fun saveToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
@@ -41,37 +42,110 @@ interface DeviceAuthTokenStore {
|
||||
scopes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Removes both token and metadata for the normalized device/role pair. */
|
||||
fun clearToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
)
|
||||
}
|
||||
|
||||
/** SecurePrefs-backed implementation of Android gateway device-token storage. */
|
||||
class DeviceAuthStore(
|
||||
private val prefs: SecurePrefs,
|
||||
internal interface DeviceAuthStateStore {
|
||||
fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow?
|
||||
|
||||
fun readLatestDeviceAuthDeviceId(): String?
|
||||
|
||||
fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow)
|
||||
|
||||
fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
)
|
||||
|
||||
fun deleteAllDeviceAuthTokens()
|
||||
}
|
||||
|
||||
private class OpenClawSQLiteDeviceAuthStateStore(
|
||||
private val store: OpenClawSQLiteStateStore,
|
||||
) : DeviceAuthStateStore {
|
||||
override fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow? = store.readDeviceAuthToken(deviceId, role)
|
||||
|
||||
override fun readLatestDeviceAuthDeviceId(): String? = store.readLatestDeviceAuthDeviceId()
|
||||
|
||||
override fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
|
||||
store.upsertDeviceAuthToken(row)
|
||||
}
|
||||
|
||||
override fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
store.deleteDeviceAuthToken(deviceId, role)
|
||||
}
|
||||
|
||||
override fun deleteAllDeviceAuthTokens() {
|
||||
store.deleteAllDeviceAuthTokens()
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceAuthStore private constructor(
|
||||
private val context: Context,
|
||||
private val legacyPrefsOverride: SecurePrefs? = null,
|
||||
private val stateStore: DeviceAuthStateStore,
|
||||
) : DeviceAuthTokenStore {
|
||||
constructor(
|
||||
context: Context,
|
||||
legacyPrefsOverride: SecurePrefs? = null,
|
||||
) : this(
|
||||
context = context,
|
||||
legacyPrefsOverride = legacyPrefsOverride,
|
||||
stateStore = OpenClawSQLiteDeviceAuthStateStore(OpenClawSQLiteStateStore(context)),
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
fun createForTesting(
|
||||
context: Context,
|
||||
legacyPrefsOverride: SecurePrefs? = null,
|
||||
stateStoreOverride: DeviceAuthStateStore,
|
||||
): DeviceAuthStore =
|
||||
DeviceAuthStore(
|
||||
context = context,
|
||||
legacyPrefsOverride = legacyPrefsOverride,
|
||||
stateStore = stateStoreOverride,
|
||||
)
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val legacyPrefs by lazy { legacyPrefsOverride ?: SecurePrefs(context) }
|
||||
|
||||
override fun loadEntry(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): DeviceAuthEntry? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val metadata =
|
||||
prefs
|
||||
.getString(metadataKey(deviceId, role))
|
||||
?.let { raw ->
|
||||
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
|
||||
val row =
|
||||
stateStore.readDeviceAuthToken(normalizedDevice, normalizedRole)
|
||||
?: return migrateLegacyEntryIfNoSqliteAuthRows(normalizedDevice, normalizedRole)
|
||||
val token =
|
||||
legacyPrefs
|
||||
.getString(tokenKey(normalizedDevice, normalizedRole))
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: row.token.trim().takeIf { it.isNotEmpty() && it != sqliteSecurePrefsTokenMarker }?.also {
|
||||
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), it)
|
||||
stateStore.upsertDeviceAuthToken(row.copy(token = sqliteSecurePrefsTokenMarker))
|
||||
}
|
||||
?: return null
|
||||
return DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = metadata?.scopes ?: emptyList(),
|
||||
updatedAtMs = metadata?.updatedAtMs ?: 0L,
|
||||
scopes = decodeScopes(row.scopesJson),
|
||||
updatedAtMs = row.updatedAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,16 +155,35 @@ class DeviceAuthStore(
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val normalizedScopes = normalizeScopes(scopes)
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
prefs.putString(
|
||||
metadataKey(deviceId, role),
|
||||
json.encodeToString(
|
||||
PersistedDeviceAuthMetadata(
|
||||
scopes = normalizedScopes,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
val latestDeviceId = stateStore.readLatestDeviceAuthDeviceId()
|
||||
val shouldSeedSameDeviceLegacyRoles = latestDeviceId == null
|
||||
val sqliteDeviceChanged = latestDeviceId != null && latestDeviceId != normalizedDevice
|
||||
val shouldDropLegacyAuth =
|
||||
sqliteDeviceChanged ||
|
||||
legacyPrefs.keysWithPrefix(deviceAuthTokenPrefix).any {
|
||||
!it.startsWith(tokenKeyPrefix(normalizedDevice))
|
||||
}
|
||||
if (sqliteDeviceChanged) {
|
||||
stateStore.deleteAllDeviceAuthTokens()
|
||||
}
|
||||
if (shouldDropLegacyAuth) {
|
||||
removeForeignLegacyEntries(normalizedDevice)
|
||||
}
|
||||
if (shouldSeedSameDeviceLegacyRoles) {
|
||||
migrateLegacyEntriesForDevice(normalizedDevice)
|
||||
}
|
||||
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), token.trim())
|
||||
removeLegacyMetadata(normalizedDevice, normalizedRole)
|
||||
stateStore.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
token = sqliteSecurePrefsTokenMarker,
|
||||
scopesJson = json.encodeToString(normalizedScopes),
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -99,44 +192,133 @@ class DeviceAuthStore(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
prefs.remove(metadataKey(deviceId, role))
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
removeLegacyEntry(normalizedDevice, normalizedRole)
|
||||
stateStore.deleteDeviceAuthToken(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
)
|
||||
}
|
||||
|
||||
private fun migrateLegacyEntryIfNoSqliteAuthRows(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): DeviceAuthEntry? {
|
||||
if (stateStore.readLatestDeviceAuthDeviceId() != null) {
|
||||
removeLegacyEntry(normalizedDevice, normalizedRole)
|
||||
return null
|
||||
}
|
||||
return migrateLegacyEntriesForDevice(normalizedDevice)[normalizedRole]
|
||||
}
|
||||
|
||||
private fun migrateLegacyEntriesForDevice(normalizedDevice: String): Map<String, DeviceAuthEntry> {
|
||||
val prefix = tokenKeyPrefix(normalizedDevice)
|
||||
return legacyPrefs
|
||||
.keysWithPrefix(prefix)
|
||||
.mapNotNull { key ->
|
||||
val role = normalizeRole(key.removePrefix(prefix))
|
||||
if (role.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
migrateLegacyEntry(normalizedDevice, role)?.let { role to it }
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun migrateLegacyEntry(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): DeviceAuthEntry? {
|
||||
val token =
|
||||
legacyPrefs
|
||||
.getString(tokenKey(normalizedDevice, normalizedRole))
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: return null
|
||||
val metadata =
|
||||
legacyPrefs
|
||||
.getString(metadataKey(normalizedDevice, normalizedRole))
|
||||
?.let { raw -> runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull() }
|
||||
val entry =
|
||||
DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = normalizeScopes(metadata?.scopes ?: emptyList()),
|
||||
updatedAtMs = metadata?.updatedAtMs?.takeIf { it > 0L } ?: System.currentTimeMillis(),
|
||||
)
|
||||
val migrated =
|
||||
runCatching {
|
||||
stateStore.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
token = sqliteSecurePrefsTokenMarker,
|
||||
scopesJson = json.encodeToString(entry.scopes),
|
||||
updatedAtMs = entry.updatedAtMs,
|
||||
),
|
||||
)
|
||||
}.isSuccess
|
||||
if (migrated) {
|
||||
legacyPrefs.putString(tokenKey(normalizedDevice, normalizedRole), entry.token)
|
||||
removeLegacyMetadata(normalizedDevice, normalizedRole)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private fun removeLegacyMetadata(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
) {
|
||||
legacyPrefs.remove(metadataKey(normalizedDevice, normalizedRole))
|
||||
}
|
||||
|
||||
private fun removeLegacyEntry(
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
) {
|
||||
legacyPrefs.remove(tokenKey(normalizedDevice, normalizedRole))
|
||||
legacyPrefs.remove(metadataKey(normalizedDevice, normalizedRole))
|
||||
}
|
||||
|
||||
private fun removeForeignLegacyEntries(normalizedDevice: String) {
|
||||
val currentTokenPrefix = tokenKeyPrefix(normalizedDevice)
|
||||
legacyPrefs
|
||||
.keysWithPrefix(deviceAuthTokenPrefix)
|
||||
.filterNot { it.startsWith(currentTokenPrefix) }
|
||||
.forEach { legacyPrefs.remove(it) }
|
||||
val currentMetadataPrefix = "$deviceAuthMetadataPrefix$normalizedDevice."
|
||||
legacyPrefs
|
||||
.keysWithPrefix(deviceAuthMetadataPrefix)
|
||||
.filterNot { it.startsWith(currentMetadataPrefix) }
|
||||
.forEach { legacyPrefs.remove(it) }
|
||||
}
|
||||
|
||||
private fun tokenKeyPrefix(normalizedDevice: String): String = "$deviceAuthTokenPrefix$normalizedDevice."
|
||||
|
||||
private fun tokenKey(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
// Keep key normalization shared with metadata keys so token and metadata
|
||||
// are added/removed as one logical auth entry.
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): String = "${tokenKeyPrefix(normalizedDevice)}$normalizedRole"
|
||||
|
||||
private fun metadataKey(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
normalizedDevice: String,
|
||||
normalizedRole: String,
|
||||
): String = "$deviceAuthMetadataPrefix$normalizedDevice.$normalizedRole"
|
||||
|
||||
private fun decodeScopes(raw: String): List<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw) }
|
||||
.getOrDefault(emptyList())
|
||||
.let(::normalizeScopes)
|
||||
|
||||
/** Normalizes device ids before they become encrypted preference key segments. */
|
||||
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
|
||||
|
||||
/** Normalizes role names so node/operator token slots are stable across callers. */
|
||||
private fun normalizeRole(role: String): String = role.trim().lowercase()
|
||||
|
||||
/** Stores scopes in deterministic order for display and restart comparisons. */
|
||||
private fun normalizeScopes(scopes: List<String>): List<String> =
|
||||
scopes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
// Persist deterministic scope lists because they are displayed and may be
|
||||
// compared across process restarts.
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Persistent Ed25519 identity used to register this Android node with gateways. */
|
||||
@Serializable
|
||||
data class DeviceIdentity(
|
||||
val deviceId: String,
|
||||
@@ -16,44 +15,40 @@ data class DeviceIdentity(
|
||||
val createdAtMs: Long,
|
||||
)
|
||||
|
||||
/** Owns device identity generation, persistence, and auth payload signatures. */
|
||||
class DeviceIdentityStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
private val stateStore = OpenClawSQLiteStateStore(context)
|
||||
private val legacyIdentityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
|
||||
@Volatile private var cachedIdentity: DeviceIdentity? = null
|
||||
|
||||
/** Loads the persisted identity or creates one, repairing old device-id drift. */
|
||||
@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
|
||||
}
|
||||
if (legacyIdentityFile.exists()) {
|
||||
val migrated = migrateLegacyIdentity()
|
||||
cachedIdentity = migrated
|
||||
return migrated
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
cachedIdentity = fresh
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** Signs gateway connect payload text with the persisted Ed25519 private key. */
|
||||
fun signPayload(
|
||||
payload: String,
|
||||
identity: DeviceIdentity,
|
||||
): String? =
|
||||
try {
|
||||
// Use BC lightweight API directly; R8 can break JCA provider registration.
|
||||
// Use BC lightweight API directly — JCA provider registration is broken by R8
|
||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
val pkInfo =
|
||||
org.bouncycastle.asn1.pkcs.PrivateKeyInfo
|
||||
@@ -78,7 +73,6 @@ class DeviceIdentityStore(
|
||||
null
|
||||
}
|
||||
|
||||
/** Verifies a signature against the persisted public key for debug diagnostics. */
|
||||
fun verifySelfSignature(
|
||||
payload: String,
|
||||
signatureBase64Url: String,
|
||||
@@ -102,16 +96,12 @@ class DeviceIdentityStore(
|
||||
false
|
||||
}
|
||||
|
||||
/** Decodes gateway URL-safe base64 signatures, accepting unpadded input. */
|
||||
private fun base64UrlDecode(input: String): ByteArray {
|
||||
val normalized = input.replace('-', '+').replace('_', '/')
|
||||
// Android Base64 expects padded input; gateway signatures are URL-safe
|
||||
// unpadded strings.
|
||||
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
|
||||
return Base64.decode(padded, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
/** Returns the public key in the gateway's unpadded URL-safe base64 format. */
|
||||
fun publicKeyBase64Url(identity: DeviceIdentity): String? =
|
||||
try {
|
||||
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
@@ -120,38 +110,80 @@ class DeviceIdentityStore(
|
||||
null
|
||||
}
|
||||
|
||||
private fun load(): DeviceIdentity? = readIdentity(identityFile)
|
||||
private fun load(): DeviceIdentity? {
|
||||
val row = stateStore.readDeviceIdentity(IDENTITY_KEY) ?: return null
|
||||
return readIdentity(row)
|
||||
?: throw IllegalStateException(
|
||||
"Stored OpenClaw device identity is invalid. Run openclaw doctor --fix.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun readIdentity(file: File): DeviceIdentity? {
|
||||
return try {
|
||||
if (!file.exists()) return null
|
||||
val raw = file.readText(Charsets.UTF_8)
|
||||
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
|
||||
if (decoded.deviceId.isBlank() ||
|
||||
decoded.publicKeyRawBase64.isBlank() ||
|
||||
decoded.privateKeyPkcs8Base64.isBlank()
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
decoded
|
||||
private fun migrateLegacyIdentity(): DeviceIdentity {
|
||||
val raw =
|
||||
try {
|
||||
legacyIdentityFile.readText(Charsets.UTF_8)
|
||||
} catch (error: Throwable) {
|
||||
throw IllegalStateException("Failed to read legacy OpenClaw device identity.", error)
|
||||
}
|
||||
val identity =
|
||||
runCatching { json.decodeFromString(DeviceIdentity.serializer(), raw) }
|
||||
.getOrNull()
|
||||
?.let(::normalizeRawIdentity)
|
||||
?: throw IllegalStateException(
|
||||
"Legacy OpenClaw device identity is invalid. Run openclaw doctor --fix.",
|
||||
)
|
||||
save(identity)
|
||||
legacyIdentityFile.delete()
|
||||
return identity
|
||||
}
|
||||
|
||||
private fun normalizeRawIdentity(identity: DeviceIdentity): DeviceIdentity? =
|
||||
try {
|
||||
if (identity.publicKeyRawBase64.isBlank() || identity.privateKeyPkcs8Base64.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val publicRaw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
val privateDer = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
if (publicRaw.size != ED25519_KEY_SIZE || privateDer.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val normalized = identity.copy(deviceId = sha256Hex(publicRaw))
|
||||
if (!hasMatchingKeyPair(normalized)) {
|
||||
return null
|
||||
}
|
||||
normalized
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readIdentity(row: OpenClawSQLiteDeviceIdentityRow): DeviceIdentity? =
|
||||
PersistedDeviceIdentity(
|
||||
deviceId = row.deviceId,
|
||||
publicKeyPem = row.publicKeyPem,
|
||||
privateKeyPem = row.privateKeyPem,
|
||||
createdAtMs = row.createdAtMs,
|
||||
).toRuntimeIdentity()?.takeIf(::hasMatchingKeyPair)
|
||||
|
||||
private fun hasMatchingKeyPair(identity: DeviceIdentity): Boolean {
|
||||
val signature = signPayload(KEYPAIR_VALIDATION_PAYLOAD, identity) ?: return false
|
||||
return verifySelfSignature(KEYPAIR_VALIDATION_PAYLOAD, signature, identity)
|
||||
}
|
||||
|
||||
private fun save(identity: DeviceIdentity) {
|
||||
try {
|
||||
identityFile.parentFile?.mkdirs()
|
||||
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort only
|
||||
}
|
||||
val persisted = PersistedDeviceIdentity.fromRuntimeIdentity(identity)
|
||||
stateStore.writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = persisted.deviceId,
|
||||
publicKeyPem = persisted.publicKeyPem,
|
||||
privateKeyPem = persisted.privateKeyPem,
|
||||
createdAtMs = persisted.createdAtMs,
|
||||
),
|
||||
identityKey = IDENTITY_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
// Use BC lightweight API directly to avoid JCA provider issues with R8.
|
||||
// Use BC lightweight API directly to avoid JCA provider issues with R8
|
||||
val kpGen =
|
||||
org.bouncycastle.crypto.generators
|
||||
.Ed25519KeyPairGenerator()
|
||||
@@ -164,8 +196,7 @@ class DeviceIdentityStore(
|
||||
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
val rawPublic = pubKey.encoded // 32 bytes
|
||||
val deviceId = sha256Hex(rawPublic)
|
||||
// Store private key as PKCS8 so signPayload can parse the same persisted
|
||||
// shape after app restarts and upgrades.
|
||||
// Encode private key as PKCS8 for storage
|
||||
val privKeyInfo =
|
||||
org.bouncycastle.crypto.util.PrivateKeyInfoFactory
|
||||
.createPrivateKeyInfo(privKey)
|
||||
@@ -178,15 +209,6 @@ class DeviceIdentityStore(
|
||||
)
|
||||
}
|
||||
|
||||
/** Re-derives the stable device id from the raw Ed25519 public key bytes. */
|
||||
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
|
||||
try {
|
||||
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
|
||||
sha256Hex(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = CharArray(digest.size * 2)
|
||||
@@ -205,7 +227,92 @@ class DeviceIdentityStore(
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedDeviceIdentity(
|
||||
val version: Int = 1,
|
||||
val deviceId: String,
|
||||
val publicKeyPem: String,
|
||||
val privateKeyPem: String,
|
||||
val createdAtMs: Long,
|
||||
) {
|
||||
fun toRuntimeIdentity(): DeviceIdentity? {
|
||||
if (version != 1 || deviceId.isBlank() || publicKeyPem.isBlank() || privateKeyPem.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val publicDer = decodePem(publicKeyPem, "PUBLIC KEY") ?: return null
|
||||
if (!publicDer.startsWith(PUBLIC_KEY_INFO_PREFIX)) return null
|
||||
val publicRaw = publicDer.copyOfRange(PUBLIC_KEY_INFO_PREFIX.size, publicDer.size)
|
||||
if (publicRaw.size != ED25519_KEY_SIZE) return null
|
||||
val derivedDeviceId = sha256HexStatic(publicRaw)
|
||||
if (derivedDeviceId != deviceId.lowercase()) return null
|
||||
val privateDer = decodePem(privateKeyPem, "PRIVATE KEY") ?: return null
|
||||
return DeviceIdentity(
|
||||
deviceId = derivedDeviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(publicRaw, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateDer, Base64.NO_WRAP),
|
||||
createdAtMs = createdAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromRuntimeIdentity(identity: DeviceIdentity): PersistedDeviceIdentity {
|
||||
val publicRaw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
val privateDer = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
return PersistedDeviceIdentity(
|
||||
deviceId = identity.deviceId,
|
||||
publicKeyPem = encodePem("PUBLIC KEY", PUBLIC_KEY_INFO_PREFIX + publicRaw),
|
||||
privateKeyPem = encodePem("PRIVATE KEY", privateDer),
|
||||
createdAtMs = identity.createdAtMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val IDENTITY_KEY = "default"
|
||||
private const val KEYPAIR_VALIDATION_PAYLOAD = "openclaw-device-identity-keypair-validation"
|
||||
private const val ED25519_KEY_SIZE = 32
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
private val PUBLIC_KEY_INFO_PREFIX =
|
||||
byteArrayOf(0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00)
|
||||
|
||||
private fun ByteArray.startsWith(prefix: ByteArray): Boolean = size >= prefix.size && prefix.indices.all { this[it] == prefix[it] }
|
||||
|
||||
private fun encodePem(
|
||||
label: String,
|
||||
bytes: ByteArray,
|
||||
): String {
|
||||
val body = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
val wrapped = body.chunked(64).joinToString("\n")
|
||||
return "-----BEGIN $label-----\n$wrapped\n-----END $label-----\n"
|
||||
}
|
||||
|
||||
private fun decodePem(
|
||||
pem: String,
|
||||
label: String,
|
||||
): ByteArray? {
|
||||
val header = "-----BEGIN $label-----"
|
||||
val footer = "-----END $label-----"
|
||||
val trimmed = pem.trim()
|
||||
if (!trimmed.startsWith(header) || !trimmed.endsWith(footer)) return null
|
||||
val body =
|
||||
trimmed
|
||||
.removePrefix(header)
|
||||
.removeSuffix(footer)
|
||||
.replace("\\s".toRegex(), "")
|
||||
return runCatching { Base64.decode(body, Base64.DEFAULT) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun sha256HexStatic(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = CharArray(digest.size * 2)
|
||||
var i = 0
|
||||
for (byte in digest) {
|
||||
val v = byte.toInt() and 0xff
|
||||
out[i++] = HEX[v ushr 4]
|
||||
out[i++] = HEX[v and 0x0f]
|
||||
}
|
||||
return String(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,6 @@ import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
|
||||
*/
|
||||
class GatewayDiscovery(
|
||||
context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -66,11 +63,9 @@ class GatewayDiscovery(
|
||||
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
/** Short diagnostic text shown by connect UI while discovery is running. */
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private var unicastJob: Job? = null
|
||||
@@ -135,8 +130,6 @@ class GatewayDiscovery(
|
||||
val cm = connectivity ?: return
|
||||
cm.activeNetwork?.let(availableNetworks::add)
|
||||
try {
|
||||
// Track all networks so wide-area DNS can prefer VPN/split-DNS answers
|
||||
// even when Android's active network is not the VPN.
|
||||
cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
|
||||
} catch (_: Throwable) {
|
||||
// ignore (best-effort)
|
||||
@@ -175,7 +168,6 @@ class GatewayDiscovery(
|
||||
|
||||
private fun resolve(serviceInfo: NsdServiceInfo) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Android 14+ streams service updates; older releases require one-shot resolve calls.
|
||||
resolveWithServiceInfoCallback(serviceInfo)
|
||||
} else {
|
||||
resolveLegacy(serviceInfo)
|
||||
@@ -263,7 +255,6 @@ class GatewayDiscovery(
|
||||
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
||||
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
// Local NSD gives the socket host/port; TXT ports are retained as gateway metadata only.
|
||||
localById[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
@@ -297,7 +288,6 @@ class GatewayDiscovery(
|
||||
|
||||
private fun publish() {
|
||||
_gateways.value =
|
||||
// Merge local and wide-area results deterministically for stable UI selection.
|
||||
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||
_statusText.value = buildStatusText()
|
||||
}
|
||||
@@ -379,7 +369,6 @@ class GatewayDiscovery(
|
||||
?: resolveHostUnicast(targetFqdn)
|
||||
?: continue
|
||||
|
||||
// Wide-area DNS-SD may put TXT in additional records; fall back to a direct TXT query.
|
||||
val txtFromPtr =
|
||||
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
|
||||
.orEmpty()
|
||||
@@ -465,7 +454,6 @@ class GatewayDiscovery(
|
||||
val system = queryViaSystemDns(query)
|
||||
if (records(system, Section.ANSWER).any { it.type == type }) return system
|
||||
|
||||
// Android's DnsResolver can miss split-DNS answers; retry with dnsjava against network DNS servers.
|
||||
val direct = createDirectResolver() ?: return system
|
||||
return try {
|
||||
val msg = direct.send(query)
|
||||
@@ -560,7 +548,6 @@ class GatewayDiscovery(
|
||||
|
||||
val candidateNetworks =
|
||||
buildList {
|
||||
// Put VPN DNS first so Tailscale split-horizon names win over public DNS.
|
||||
trackedNetworks(cm)
|
||||
.firstOrNull { n ->
|
||||
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
/** Resolved gateway address and optional metadata discovered from Bonjour/manual entry. */
|
||||
data class GatewayEndpoint(
|
||||
val stableId: String,
|
||||
val name: String,
|
||||
@@ -14,7 +13,6 @@ data class GatewayEndpoint(
|
||||
val tlsFingerprintSha256: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
/** Builds a stable manual endpoint key that survives display-name changes. */
|
||||
fun manual(
|
||||
host: String,
|
||||
port: Int,
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.os.Build
|
||||
import java.net.InetAddress
|
||||
import java.util.Locale
|
||||
|
||||
/** Returns true only for loopback hosts safe to treat as local gateway origins. */
|
||||
internal fun isLoopbackGatewayHost(
|
||||
rawHost: String?,
|
||||
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
|
||||
@@ -19,12 +18,9 @@ internal fun isLoopbackGatewayHost(
|
||||
host = host.dropLast(1)
|
||||
}
|
||||
val zoneIndex = host.indexOf('%')
|
||||
// Scoped IPv6 literals are not stable origin identifiers; reject them for
|
||||
// loopback trust instead of guessing which interface the zone names.
|
||||
if (zoneIndex >= 0) return false
|
||||
if (host.isEmpty()) return false
|
||||
if (host == "localhost") return true
|
||||
// Android emulator maps host loopback through this bridge alias.
|
||||
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
|
||||
|
||||
parseIpv4Address(host)?.let { ipv4 ->
|
||||
@@ -48,7 +44,6 @@ internal fun isLoopbackGatewayHost(
|
||||
return isMappedIpv4 && address[12] == 127.toByte()
|
||||
}
|
||||
|
||||
/** Allows cleartext only for loopback and private/link-local network ranges. */
|
||||
internal fun isLocalCleartextGatewayHost(
|
||||
rawHost: String?,
|
||||
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
|
||||
@@ -64,8 +59,6 @@ internal fun isLocalCleartextGatewayHost(
|
||||
}
|
||||
val zoneIndex = host.indexOf('%')
|
||||
if (zoneIndex >= 0) {
|
||||
// Link-local cleartext policy is about the address range; strip the
|
||||
// interface zone before InetAddress parsing rejects otherwise valid hosts.
|
||||
host = host.substring(0, zoneIndex)
|
||||
}
|
||||
if (host.isEmpty()) return false
|
||||
@@ -114,7 +107,6 @@ private fun isAndroidEmulatorRuntime(): Boolean {
|
||||
product.contains("simulator")
|
||||
}
|
||||
|
||||
/** Parses strict dotted-quad IPv4, rejecting shorthand and out-of-range octets. */
|
||||
private fun parseIpv4Address(host: String): ByteArray? {
|
||||
val parts = host.split('.')
|
||||
if (parts.size != 4) return null
|
||||
@@ -127,5 +119,4 @@ private fun parseIpv4Address(host: String): ByteArray? {
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** Cheap prefilter before handing potential IPv6 literals to InetAddress. */
|
||||
private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.'
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
/** Gateway protocol version emitted by Android node clients. */
|
||||
const val GATEWAY_PROTOCOL_VERSION = 4
|
||||
|
||||
/** Oldest gateway protocol version this Android client can speak safely. */
|
||||
const val GATEWAY_MIN_PROTOCOL_VERSION = 4
|
||||
|
||||
@@ -33,9 +33,6 @@ import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Identity advertised during gateway connect; these fields become the device row users approve.
|
||||
*/
|
||||
data class GatewayClientInfo(
|
||||
val id: String,
|
||||
val displayName: String?,
|
||||
@@ -47,9 +44,6 @@ data class GatewayClientInfo(
|
||||
val modelIdentifier: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Role, scopes, commands, and permission snapshot sent with the connect frame.
|
||||
*/
|
||||
data class GatewayConnectOptions(
|
||||
val role: String,
|
||||
val scopes: List<String>,
|
||||
@@ -68,9 +62,6 @@ private enum class GatewayConnectAuthSource {
|
||||
NONE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured auth failure guidance from the gateway, preserved for reconnect and UI decisions.
|
||||
*/
|
||||
data class GatewayConnectErrorDetails(
|
||||
val code: String?,
|
||||
val canRetryWithDeviceToken: Boolean,
|
||||
@@ -79,9 +70,6 @@ data class GatewayConnectErrorDetails(
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Server hello fields cached by the Android runtime after a successful connect.
|
||||
*/
|
||||
data class GatewayHelloSummary(
|
||||
val serverName: String?,
|
||||
val remoteAddress: String?,
|
||||
@@ -111,9 +99,6 @@ private class GatewayConnectFailure(
|
||||
val gatewayError: GatewaySession.ErrorShape,
|
||||
) : IllegalStateException(gatewayError.message)
|
||||
|
||||
/**
|
||||
* WebSocket RPC session that maintains gateway connection lifecycle, auth, events, and node invokes.
|
||||
*/
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
@@ -129,9 +114,6 @@ class GatewaySession(
|
||||
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway node.invoke request routed to Android command handlers.
|
||||
*/
|
||||
data class InvokeRequest(
|
||||
val id: String,
|
||||
val nodeId: String,
|
||||
@@ -161,9 +143,6 @@ class GatewaySession(
|
||||
val details: GatewayConnectErrorDetails? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Structured RPC result used by callers that need error codes without exceptions.
|
||||
*/
|
||||
data class RpcResult(
|
||||
val ok: Boolean,
|
||||
val payloadJson: String?,
|
||||
@@ -195,15 +174,12 @@ class GatewaySession(
|
||||
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
|
||||
// One reconnect can retry a shared-token mismatch by pairing the shared token with the stored device token.
|
||||
@Volatile private var pendingDeviceTokenRetry = false
|
||||
|
||||
// Keep the mismatch retry single-shot so an invalid stored token cannot create an auth loop.
|
||||
@Volatile private var deviceTokenRetryBudgetUsed = false
|
||||
|
||||
@Volatile private var reconnectPausedForAuthFailure = false
|
||||
|
||||
/** Starts or replaces the desired gateway connection and launches the reconnect loop. */
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
@@ -226,7 +202,6 @@ class GatewaySession(
|
||||
connectionToClose?.closeQuietly()
|
||||
}
|
||||
|
||||
/** Clears desired connection state, closes the socket, and stops reconnect attempts. */
|
||||
fun disconnect() {
|
||||
val jobToCancel: Job?
|
||||
val connectionToClose: Connection?
|
||||
@@ -250,7 +225,6 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces the current socket closed so the loop reconnects to the current desired endpoint. */
|
||||
fun reconnect() {
|
||||
reconnectPausedForAuthFailure = false
|
||||
currentConnection?.closeQuietly()
|
||||
@@ -258,7 +232,6 @@ class GatewaySession(
|
||||
|
||||
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
|
||||
|
||||
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
|
||||
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
|
||||
val refreshed =
|
||||
refreshPluginSurfaceUrl(
|
||||
@@ -274,7 +247,6 @@ class GatewaySession(
|
||||
|
||||
fun currentMainSessionKey(): String? = mainSessionKey
|
||||
|
||||
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
|
||||
suspend fun sendNodeEvent(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -315,7 +287,6 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
|
||||
suspend fun sendNodeEventDetailed(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
@@ -348,11 +319,9 @@ class GatewaySession(
|
||||
): JsonObject =
|
||||
buildJsonObject {
|
||||
put("event", JsonPrimitive(event))
|
||||
// Gateway node events carry payloadJSON as a string for compatibility with non-JSON payload producers.
|
||||
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
|
||||
}
|
||||
|
||||
/** Sends an RPC request and throws a code-prefixed exception when the gateway returns an error. */
|
||||
suspend fun request(
|
||||
method: String,
|
||||
paramsJson: String?,
|
||||
@@ -364,7 +333,6 @@ class GatewaySession(
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
/** Sends an RPC request and returns the structured success/error payload. */
|
||||
suspend fun requestDetailed(
|
||||
method: String,
|
||||
paramsJson: String?,
|
||||
@@ -381,7 +349,6 @@ class GatewaySession(
|
||||
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
|
||||
}
|
||||
|
||||
/** Sends an RPC request frame and reports errors asynchronously through [onError]. */
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
paramsJson: String?,
|
||||
@@ -738,7 +705,6 @@ class GatewaySession(
|
||||
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
|
||||
}
|
||||
if (shouldPersistBootstrapHandoffTokens(authSource)) {
|
||||
// Bootstrap connects can mint role-specific device tokens; store only locally trusted handoffs.
|
||||
authObj
|
||||
?.get("deviceTokens")
|
||||
.asArrayOrNull()
|
||||
@@ -759,7 +725,6 @@ class GatewaySession(
|
||||
val rawPluginSurfaceUrls = obj["pluginSurfaceUrls"].asObjectOrNull()
|
||||
val normalizedPluginSurfaceUrls =
|
||||
rawPluginSurfaceUrls?.mapNotNull { (surface, value) ->
|
||||
// Canvas URLs may be loopback gateway metadata; normalize them to the reachable Android endpoint.
|
||||
normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null)
|
||||
?.let { normalized -> surface to normalized }
|
||||
} ?: emptyList()
|
||||
@@ -832,7 +797,6 @@ class GatewaySession(
|
||||
|
||||
val connectScopes = resolveConnectScopes(selectedAuth)
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
// V3 signatures bind the auth token, nonce, role, and scopes so replayed connect frames fail.
|
||||
val payload =
|
||||
DeviceAuthPayload.buildV3(
|
||||
deviceId = identity.deviceId,
|
||||
@@ -1002,7 +966,6 @@ class GatewaySession(
|
||||
if (parsedPayload != null) {
|
||||
put("payload", parsedPayload)
|
||||
} else if (result.payloadJson != null) {
|
||||
// Preserve malformed/non-object payloads as payloadJSON so the gateway can report handler output.
|
||||
put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||
}
|
||||
result.error?.let { err ->
|
||||
@@ -1226,7 +1189,6 @@ class GatewaySession(
|
||||
if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false
|
||||
val detailCode = error.details?.code
|
||||
val recommendedNextStep = error.details?.recommendedNextStep
|
||||
// New gateways set canRetryWithDeviceToken; older builds expose equivalent string codes.
|
||||
return error.details?.canRetryWithDeviceToken == true ||
|
||||
recommendedNextStep == "retry_with_device_token" ||
|
||||
detailCode == "AUTH_TOKEN_MISMATCH"
|
||||
@@ -1251,13 +1213,10 @@ class GatewaySession(
|
||||
tls: GatewayTlsParams?,
|
||||
): Boolean {
|
||||
if (isLocalCleartextGatewayHost(endpoint.host)) return true
|
||||
// Retrying a stored device token alongside a shared token is only safe for
|
||||
// remote gateways when an existing TLS pin already identifies the endpoint.
|
||||
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
|
||||
/** Decides whether auth failures should stop reconnect churn until the user changes credentials. */
|
||||
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
|
||||
error: GatewaySession.ErrorShape,
|
||||
hasBootstrapToken: Boolean,
|
||||
@@ -1290,7 +1249,6 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** Builds the gateway WebSocket URL from endpoint authority and TLS policy. */
|
||||
internal fun buildGatewayWebSocketUrl(
|
||||
host: String,
|
||||
port: Int,
|
||||
@@ -1300,7 +1258,6 @@ internal fun buildGatewayWebSocketUrl(
|
||||
return "$scheme://${formatGatewayAuthority(host, port)}"
|
||||
}
|
||||
|
||||
/** Formats host/port for gateway URLs, including IPv6 bracket wrapping. */
|
||||
internal fun formatGatewayAuthority(
|
||||
host: String,
|
||||
port: Int,
|
||||
@@ -1351,7 +1308,6 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps invoke-result ack waits inside the gateway-supported timeout window. */
|
||||
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
|
||||
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
|
||||
return normalized.coerceIn(15_000L, 120_000L)
|
||||
|
||||
@@ -25,7 +25,6 @@ import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */
|
||||
data class GatewayTlsParams(
|
||||
val required: Boolean,
|
||||
val expectedFingerprint: String?,
|
||||
@@ -33,26 +32,22 @@ data class GatewayTlsParams(
|
||||
val stableId: String,
|
||||
)
|
||||
|
||||
/** SSL primitives installed into OkHttp when a gateway needs TLS pinning/TOFU. */
|
||||
data class GatewayTlsConfig(
|
||||
val sslSocketFactory: SSLSocketFactory,
|
||||
val trustManager: X509TrustManager,
|
||||
val hostnameVerifier: HostnameVerifier,
|
||||
)
|
||||
|
||||
/** Distinguishes non-TLS endpoints from unreachable endpoints during probing. */
|
||||
enum class GatewayTlsProbeFailure {
|
||||
TLS_UNAVAILABLE,
|
||||
ENDPOINT_UNREACHABLE,
|
||||
}
|
||||
|
||||
/** Result of probing a gateway TLS endpoint for first-use fingerprint capture. */
|
||||
data class GatewayTlsProbeResult(
|
||||
val fingerprintSha256: String? = null,
|
||||
val failure: GatewayTlsProbeFailure? = null,
|
||||
)
|
||||
|
||||
/** Builds a TLS config that supports pinned fingerprints and trust-on-first-use. */
|
||||
fun buildGatewayTlsConfig(
|
||||
params: GatewayTlsParams?,
|
||||
onStore: ((String) -> Unit)? = null,
|
||||
@@ -87,9 +82,6 @@ fun buildGatewayTlsConfig(
|
||||
return
|
||||
}
|
||||
if (params.allowTOFU) {
|
||||
// Store only after the TLS stack presents a concrete server cert; the
|
||||
// caller persists the fingerprint against the endpoint's stable id,
|
||||
// and later connects must come back through the pinned branch above.
|
||||
onStore?.invoke(fingerprint)
|
||||
return
|
||||
}
|
||||
@@ -115,7 +107,6 @@ fun buildGatewayTlsConfig(
|
||||
)
|
||||
}
|
||||
|
||||
/** Connects with a probe trust manager that captures the presented cert hash. */
|
||||
suspend fun probeGatewayTlsFingerprint(
|
||||
host: String,
|
||||
port: Int,
|
||||
@@ -141,7 +132,6 @@ suspend fun probeGatewayTlsFingerprint(
|
||||
) {
|
||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
||||
fingerprintRef.set(sha256Hex(chain[0].encoded))
|
||||
// Abort validation after capture; the probe is not deciding trust.
|
||||
throw CertificateException("gateway TLS probe captured fingerprint")
|
||||
}
|
||||
|
||||
@@ -164,8 +154,7 @@ suspend fun probeGatewayTlsFingerprint(
|
||||
socket.sslParameters = params
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// SNI is only a probe hint. IP literals and odd Bonjour names should
|
||||
// still be probed instead of failing before the TLS handshake.
|
||||
// ignore
|
||||
}
|
||||
|
||||
socket.startHandshake()
|
||||
@@ -214,7 +203,6 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** Normalizes user-visible fingerprint text to lowercase bare SHA-256 hex. */
|
||||
fun normalizeGatewayTlsFingerprint(raw: String): String {
|
||||
val stripped =
|
||||
raw
|
||||
|
||||
@@ -5,15 +5,10 @@ data class ParsedInvokeError(
|
||||
val message: String,
|
||||
val hadExplicitCode: Boolean,
|
||||
) {
|
||||
/** Gateway-facing form expected by UI and retry copy. */
|
||||
val prefixedMessage: String
|
||||
get() = "$code: $message"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses gateway invoke errors encoded as CODE: message while preserving legacy
|
||||
* plain-text errors as UNAVAILABLE.
|
||||
*/
|
||||
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
@@ -35,7 +30,6 @@ fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
|
||||
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
|
||||
}
|
||||
|
||||
/** Extracts an invoke error from a throwable without exposing blank messages. */
|
||||
fun parseInvokeErrorFromThrowable(
|
||||
err: Throwable,
|
||||
fallbackMessage: String = "error",
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import java.io.File
|
||||
|
||||
data class OpenClawSQLiteDeviceIdentityRow(
|
||||
val deviceId: String,
|
||||
val publicKeyPem: String,
|
||||
val privateKeyPem: String,
|
||||
val createdAtMs: Long,
|
||||
)
|
||||
|
||||
data class OpenClawSQLiteDeviceAuthTokenRow(
|
||||
val deviceId: String,
|
||||
val role: String,
|
||||
val token: String,
|
||||
val scopesJson: String,
|
||||
val updatedAtMs: Long,
|
||||
)
|
||||
|
||||
class OpenClawSQLiteStateStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val databaseFile = File(appContext.filesDir, "openclaw/state/openclaw.sqlite")
|
||||
|
||||
fun databaseFile(): File = databaseFile
|
||||
|
||||
@Synchronized
|
||||
fun readDeviceIdentity(identityKey: String = "default"): OpenClawSQLiteDeviceIdentityRow? {
|
||||
if (!databaseFile.exists()) return null
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT device_id, public_key_pem, private_key_pem, created_at_ms
|
||||
FROM device_identities
|
||||
WHERE identity_key = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(identityKey),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = cursor.getString(0),
|
||||
publicKeyPem = cursor.getString(1),
|
||||
privateKeyPem = cursor.getString(2),
|
||||
createdAtMs = cursor.getLong(3),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun writeDeviceIdentity(
|
||||
identity: OpenClawSQLiteDeviceIdentityRow,
|
||||
identityKey: String = "default",
|
||||
updatedAtMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put("identity_key", identityKey)
|
||||
put("device_id", identity.deviceId)
|
||||
put("public_key_pem", identity.publicKeyPem)
|
||||
put("private_key_pem", identity.privateKeyPem)
|
||||
put("created_at_ms", identity.createdAtMs)
|
||||
put("updated_at_ms", updatedAtMs)
|
||||
}
|
||||
db.insertWithOnConflict("device_identities", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow? {
|
||||
if (!databaseFile.exists()) return null
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT device_id, role, token, scopes_json, updated_at_ms
|
||||
FROM device_auth_tokens
|
||||
WHERE device_id = ? AND role = ?
|
||||
""".trimIndent(),
|
||||
arrayOf(deviceId, role),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = cursor.getString(0),
|
||||
role = cursor.getString(1),
|
||||
token = cursor.getString(2),
|
||||
scopesJson = cursor.getString(3),
|
||||
updatedAtMs = cursor.getLong(4),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun readLatestDeviceAuthDeviceId(): String? {
|
||||
if (!databaseFile.exists()) return null
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT device_id
|
||||
FROM device_auth_tokens
|
||||
ORDER BY updated_at_ms DESC, device_id ASC
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
emptyArray(),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put("device_id", row.deviceId)
|
||||
put("role", row.role)
|
||||
put("token", row.token)
|
||||
put("scopes_json", row.scopesJson)
|
||||
put("updated_at_ms", row.updatedAtMs)
|
||||
}
|
||||
db.insertWithOnConflict("device_auth_tokens", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
db.delete("device_auth_tokens", "device_id = ? AND role = ?", arrayOf(deviceId, role))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteAllDeviceAuthTokens() {
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
db.delete("device_auth_tokens", null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun readRecentNotificationPackages(limit: Int = 64): List<String> {
|
||||
if (!databaseFile.exists()) return emptyList()
|
||||
return openDatabase().use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"""
|
||||
SELECT package_name
|
||||
FROM android_notification_recent_packages
|
||||
ORDER BY sort_order ASC, package_name ASC
|
||||
LIMIT ?
|
||||
""".trimIndent(),
|
||||
arrayOf(limit.coerceAtLeast(0).toString()),
|
||||
).use { cursor ->
|
||||
val packages = mutableListOf<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
packages += cursor.getString(0)
|
||||
}
|
||||
packages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun replaceRecentNotificationPackages(
|
||||
packageNames: List<String>,
|
||||
limit: Int = 64,
|
||||
updatedAtMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val normalized =
|
||||
packageNames
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(limit.coerceAtLeast(0))
|
||||
.toList()
|
||||
openDatabase().use { db ->
|
||||
db.inWriteTransaction {
|
||||
db.delete("android_notification_recent_packages", null, null)
|
||||
normalized.forEachIndexed { index, packageName ->
|
||||
val values =
|
||||
ContentValues().apply {
|
||||
put("package_name", packageName)
|
||||
put("sort_order", index)
|
||||
put("updated_at_ms", updatedAtMs)
|
||||
}
|
||||
db.insertWithOnConflict(
|
||||
"android_notification_recent_packages",
|
||||
null,
|
||||
values,
|
||||
SQLiteDatabase.CONFLICT_REPLACE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDatabase(): SQLiteDatabase {
|
||||
databaseFile.parentFile?.mkdirs()
|
||||
val db =
|
||||
SQLiteDatabase.openDatabase(
|
||||
databaseFile.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.CREATE_IF_NECESSARY,
|
||||
)
|
||||
configure(db)
|
||||
return db
|
||||
}
|
||||
|
||||
private fun configure(db: SQLiteDatabase) {
|
||||
db.enableWriteAheadLogging()
|
||||
executePragma(db, "PRAGMA synchronous = NORMAL")
|
||||
executePragma(db, "PRAGMA busy_timeout = 30000")
|
||||
executePragma(db, "PRAGMA foreign_keys = ON")
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS device_identities (
|
||||
identity_key TEXT NOT NULL PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
public_key_pem TEXT NOT NULL,
|
||||
private_key_pem TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_device_identities_device
|
||||
ON device_identities(device_id, updated_at_ms DESC)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS device_auth_tokens (
|
||||
device_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
scopes_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (device_id, role)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated
|
||||
ON device_auth_tokens(updated_at_ms DESC, device_id, role)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS android_notification_recent_packages (
|
||||
package_name TEXT NOT NULL PRIMARY KEY,
|
||||
sort_order INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_android_notification_recent_packages_order
|
||||
ON android_notification_recent_packages(sort_order, package_name)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun executePragma(
|
||||
db: SQLiteDatabase,
|
||||
sql: String,
|
||||
) {
|
||||
db.rawQuery(sql, null).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
// Some PRAGMA assignments return their new value; reading it closes the cursor cleanly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.inWriteTransaction(body: () -> Unit) {
|
||||
beginTransaction()
|
||||
try {
|
||||
body()
|
||||
setTransactionSuccessful()
|
||||
} finally {
|
||||
endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Android bridge for applying gateway A2UI messages to the canvas WebView.
|
||||
*/
|
||||
class A2UIHandler(
|
||||
private val canvas: CanvasController,
|
||||
private val json: Json,
|
||||
@@ -24,7 +21,6 @@ class A2UIHandler(
|
||||
fun resolveA2uiHostUrl(): String? {
|
||||
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
|
||||
// Prefer node-advertised canvas host; operator URL is a fallback for older hello payloads.
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
@@ -40,7 +36,6 @@ class A2UIHandler(
|
||||
}
|
||||
|
||||
canvas.navigate(a2uiUrl)
|
||||
// A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command.
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
@@ -70,7 +65,6 @@ class A2UIHandler(
|
||||
if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
// JSONL keeps large A2UI streams model-friendly while still validating each message.
|
||||
val messages =
|
||||
jsonl
|
||||
.lineSequence()
|
||||
@@ -104,7 +98,6 @@ class A2UIHandler(
|
||||
lineNumber: Int,
|
||||
) {
|
||||
if (msg.containsKey("createSurface")) {
|
||||
// Android scaffold currently implements A2UI v0.8, not the v0.9 createSurface shape.
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||
)
|
||||
|
||||
@@ -20,18 +20,12 @@ import java.util.TimeZone
|
||||
|
||||
private const val DEFAULT_CALENDAR_LIMIT = 50
|
||||
|
||||
/**
|
||||
* Parsed calendar.events request; times are epoch millis for CalendarContract queries.
|
||||
*/
|
||||
internal data class CalendarEventsRequest(
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed calendar.add request before resolving the target Android calendar.
|
||||
*/
|
||||
internal data class CalendarAddRequest(
|
||||
val title: String,
|
||||
val startMs: Long,
|
||||
@@ -43,9 +37,6 @@ internal data class CalendarAddRequest(
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Normalized calendar event returned through gateway calendar commands.
|
||||
*/
|
||||
internal data class CalendarEventRecord(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
@@ -56,9 +47,6 @@ internal data class CalendarEventRecord(
|
||||
val calendarTitle: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Injectable CalendarProvider facade for command tests and Android runtime access.
|
||||
*/
|
||||
internal interface CalendarDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
@@ -90,7 +78,6 @@ private object SystemCalendarDataSource : CalendarDataSource {
|
||||
): List<CalendarEventRecord> {
|
||||
val resolver = context.contentResolver
|
||||
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
|
||||
// Instances expands recurring events inside the requested time window.
|
||||
ContentUris.appendId(builder, request.startMs)
|
||||
ContentUris.appendId(builder, request.endMs)
|
||||
val projection =
|
||||
@@ -168,12 +155,10 @@ private object SystemCalendarDataSource : CalendarDataSource {
|
||||
calendarTitle: String?,
|
||||
): Long {
|
||||
if (calendarId != null) {
|
||||
// Explicit id wins over title/default selection and must already exist.
|
||||
if (calendarExists(resolver, calendarId)) return calendarId
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
|
||||
}
|
||||
if (!calendarTitle.isNullOrEmpty()) {
|
||||
// Title lookup is exact to avoid adding events to a similarly named calendar.
|
||||
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
|
||||
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
|
||||
}
|
||||
@@ -224,7 +209,6 @@ private object SystemCalendarDataSource : CalendarDataSource {
|
||||
projection,
|
||||
"${CalendarContract.Calendars.VISIBLE}=1",
|
||||
null,
|
||||
// Prefer Android's primary visible calendar, then lowest id for deterministic fallback.
|
||||
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
|
||||
).use { cursor ->
|
||||
if (cursor == null || !cursor.moveToFirst()) return null
|
||||
@@ -358,7 +342,6 @@ class CalendarHandler private constructor(
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
val start = Instant.now()
|
||||
val end = start.plus(7, ChronoUnit.DAYS)
|
||||
// Default calendar read is a one-week window, not the full calendar store.
|
||||
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
|
||||
}
|
||||
val params =
|
||||
@@ -371,7 +354,6 @@ class CalendarHandler private constructor(
|
||||
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
|
||||
val resolvedStart = start ?: Instant.now()
|
||||
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
|
||||
// Keep model-driven calendar reads bounded.
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
|
||||
return CalendarEventsRequest(
|
||||
startMs = resolvedStart.toEpochMilli(),
|
||||
@@ -408,7 +390,6 @@ class CalendarHandler private constructor(
|
||||
private fun parseISO(raw: String?): Instant? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
// Gateway calendar payloads use UTC ISO-8601 instants for unambiguous Android storage.
|
||||
return try {
|
||||
Instant.parse(value)
|
||||
} catch (_: Throwable) {
|
||||
|
||||
@@ -41,25 +41,19 @@ import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* CameraX-backed capture service used by gateway camera commands.
|
||||
*/
|
||||
class CameraCaptureManager(
|
||||
private val context: Context,
|
||||
) {
|
||||
/** Base64 JSON response for camera.snap after resize and JPEG budget enforcement. */
|
||||
data class Payload(
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
/** Temporary MP4 response for camera.clip before CameraHandler validates invoke size. */
|
||||
data class FilePayload(
|
||||
val file: File,
|
||||
val durationMs: Long,
|
||||
val hasAudio: Boolean,
|
||||
)
|
||||
|
||||
/** Camera device metadata exposed through camera.list. */
|
||||
data class CameraDeviceInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@@ -71,19 +65,14 @@ class CameraCaptureManager(
|
||||
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
|
||||
/** Supplies the foreground Activity lifecycle required by CameraX use-case binding. */
|
||||
fun attachLifecycleOwner(owner: LifecycleOwner) {
|
||||
// CameraX binds use cases to an Activity lifecycle; background services cannot capture alone.
|
||||
lifecycleOwner = owner
|
||||
}
|
||||
|
||||
/** Supplies the Activity-owned permission launcher used by camera and microphone commands. */
|
||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
||||
// Permission prompts must be launched by the Activity that owns the ActivityResult registry.
|
||||
permissionRequester = requester
|
||||
}
|
||||
|
||||
/** Lists CameraX devices with stable Camera2 ids where available. */
|
||||
suspend fun listDevices(): List<CameraDeviceInfo> =
|
||||
withContext(Dispatchers.Main) {
|
||||
val provider = context.cameraProvider()
|
||||
@@ -118,7 +107,6 @@ class CameraCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures one still image and returns a gateway-sized JPEG payload. */
|
||||
suspend fun snap(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
@@ -134,7 +122,6 @@ class CameraCaptureManager(
|
||||
val selector = resolveCameraSelector(provider, facing, deviceId)
|
||||
|
||||
provider.unbindAll()
|
||||
// Bind only the still capture use case; CameraX owns camera open/close through the lifecycle owner.
|
||||
provider.bindToLifecycle(owner, selector, capture)
|
||||
|
||||
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
|
||||
@@ -192,7 +179,6 @@ class CameraCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a short MP4 clip into a temporary cache file for the caller to encode/delete. */
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): FilePayload =
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -317,7 +303,6 @@ class CameraCaptureManager(
|
||||
orientation: Int,
|
||||
): Bitmap {
|
||||
val matrix = Matrix()
|
||||
// CameraX JPEG bytes keep sensor orientation in EXIF; normalize before resizing/encoding.
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
@@ -380,7 +365,6 @@ class CameraCaptureManager(
|
||||
}
|
||||
return CameraSelector
|
||||
.Builder()
|
||||
// CameraX selectors are filters over CameraInfo; pin by Camera2 id for stable device selection.
|
||||
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
|
||||
.build()
|
||||
}
|
||||
@@ -435,9 +419,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns JPEG bytes plus EXIF orientation so callers can normalize the decoded bitmap.
|
||||
*/
|
||||
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
|
||||
private suspend fun ImageCapture.takeJpegWithExif(
|
||||
executor: Executor,
|
||||
tempDir: File,
|
||||
|
||||
@@ -3,7 +3,6 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.CameraHudKind
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -16,23 +15,15 @@ import kotlinx.serialization.json.put
|
||||
|
||||
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
|
||||
/**
|
||||
* Raw MP4 size guard before base64 encoding the clip into a node.invoke response.
|
||||
*/
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
|
||||
/**
|
||||
* Gateway camera command adapter that adds HUD feedback and payload-size enforcement.
|
||||
*/
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
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>,
|
||||
) {
|
||||
/** Handles camera.list by exposing CameraX devices through gateway metadata. */
|
||||
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult =
|
||||
try {
|
||||
val devices = camera.listDevices()
|
||||
@@ -60,18 +51,13 @@ class CameraHandler(
|
||||
GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
|
||||
/** Handles camera.snap with HUD progress, flash feedback, and normalized invoke errors. */
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
|
||||
fun camLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
logFile?.appendText("[$ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.snap: $msg")
|
||||
android.util.Log.w("openclaw", "camera.snap[$ts]: $msg")
|
||||
}
|
||||
try {
|
||||
logFile?.writeText("") // clear
|
||||
camLog("starting, params=$paramsJson")
|
||||
camLog("calling showCameraHud")
|
||||
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
|
||||
@@ -100,20 +86,15 @@ class CameraHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles camera.clip and keeps external audio capture paused while camera audio is active. */
|
||||
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
|
||||
fun clipLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
android.util.Log.w("openclaw", "camera.clip[$ts]: $msg")
|
||||
}
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
|
||||
clipLog("calling showCameraHud")
|
||||
showCameraHud("Recording…", CameraHudKind.Recording, null)
|
||||
@@ -133,7 +114,6 @@ class CameraHandler(
|
||||
val rawBytes = filePayload.file.length()
|
||||
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
|
||||
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
|
||||
// Delete oversized clips before returning so cache files do not accumulate after failed invokes.
|
||||
withContext(Dispatchers.IO) { filePayload.file.delete() }
|
||||
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(
|
||||
@@ -162,7 +142,6 @@ class CameraHandler(
|
||||
clipLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
|
||||
} finally {
|
||||
// Prevent talk/transcription capture from competing with camera audio after every exit path.
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,9 @@ package ai.openclaw.app.node
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Trust helper for WebView-originated canvas/A2UI actions.
|
||||
*/
|
||||
object CanvasActionTrust {
|
||||
/** Local canvas scaffold is the only trusted file URL. */
|
||||
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
/** Accepts local scaffold or exact remote A2UI URLs advertised by the gateway. */
|
||||
fun isTrustedCanvasActionUrl(
|
||||
rawUrl: String?,
|
||||
trustedA2uiUrls: List<String>,
|
||||
@@ -33,14 +28,11 @@ object CanvasActionTrust {
|
||||
candidateUri: URI,
|
||||
trustedUrl: String,
|
||||
): Boolean {
|
||||
// Gateway-advertised URLs are capabilities. Treat malformed entries as
|
||||
// absent instead of broadening trust to same-origin or prefix matches.
|
||||
val trustedUri = parseUri(trustedUrl) ?: return false
|
||||
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
|
||||
return candidateUri == normalizedTrusted
|
||||
}
|
||||
|
||||
/** Normalizes only the URL parts allowed to vary across trusted remote A2UI URLs. */
|
||||
private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? {
|
||||
// Keep Android trust normalization aligned with iOS ScreenController:
|
||||
// exact remote URL match, scheme/host normalized, fragment ignored.
|
||||
@@ -60,7 +52,6 @@ object CanvasActionTrust {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses untrusted WebView/gateway URL text without throwing into UI event handlers. */
|
||||
private fun parseUri(raw: String): URI? =
|
||||
try {
|
||||
URI(raw)
|
||||
|
||||
@@ -23,9 +23,6 @@ import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Owns the Android WebView canvas surface used by canvas and A2UI commands.
|
||||
*/
|
||||
class CanvasController {
|
||||
enum class SnapshotFormat(
|
||||
val rawValue: String,
|
||||
@@ -63,23 +60,19 @@ class CanvasController {
|
||||
return scale(maxWidth, scaledHeight)
|
||||
}
|
||||
|
||||
/** Attaches the active WebView and replays state that may have arrived before the view existed. */
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
// Replay persisted state because WebView attachment can happen after gateway events arrive.
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
/** Detaches only the currently attached WebView instance. */
|
||||
fun detach(webView: WebView) {
|
||||
if (this.webView === webView) {
|
||||
this.webView = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigates the canvas to a remote URL or back to the bundled scaffold for blank/root input. */
|
||||
fun navigate(url: String) {
|
||||
val trimmed = url.trim()
|
||||
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
|
||||
@@ -120,7 +113,6 @@ class CanvasController {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block(wv)
|
||||
} else {
|
||||
// WebView APIs must run on the main thread.
|
||||
wv.post { block(wv) }
|
||||
}
|
||||
}
|
||||
@@ -186,7 +178,6 @@ class CanvasController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluates JavaScript against the attached WebView on the main thread. */
|
||||
suspend fun eval(javaScript: String): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
@@ -215,7 +206,6 @@ class CanvasController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
|
||||
suspend fun snapshotBase64(
|
||||
format: SnapshotFormat,
|
||||
quality: Double?,
|
||||
@@ -256,22 +246,17 @@ class CanvasController {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parsed canvas.snapshot options used by invoke dispatch.
|
||||
*/
|
||||
data class SnapshotParams(
|
||||
val format: SnapshotFormat,
|
||||
val quality: Double?,
|
||||
val maxWidth: Int?,
|
||||
)
|
||||
|
||||
/** Parses canvas.navigate params and returns blank when the payload is missing or invalid. */
|
||||
fun parseNavigateUrl(paramsJson: String?): String {
|
||||
val obj = parseParamsObject(paramsJson) ?: return ""
|
||||
return obj.string("url").trim()
|
||||
}
|
||||
|
||||
/** Parses non-blank JavaScript from canvas.eval params. */
|
||||
fun parseEvalJs(paramsJson: String?): String? {
|
||||
val obj = parseParamsObject(paramsJson) ?: return null
|
||||
val js = obj.string("javaScript").trim()
|
||||
@@ -301,11 +286,9 @@ class CanvasController {
|
||||
if (!obj.containsKey("quality")) return null
|
||||
val q = obj.double("quality") ?: Double.NaN
|
||||
if (!q.isFinite()) return null
|
||||
// Keep JPEG quality inside encoder-safe bounds; PNG ignores it.
|
||||
return q.coerceIn(0.1, 1.0)
|
||||
}
|
||||
|
||||
/** Parses canvas.snapshot params using JPEG defaults and encoder-safe bounds. */
|
||||
fun parseSnapshotParams(paramsJson: String?): SnapshotParams =
|
||||
SnapshotParams(
|
||||
format = parseSnapshotFormat(paramsJson),
|
||||
|
||||
@@ -12,9 +12,6 @@ import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Builds gateway connect metadata from current Android permissions, settings, and device identity.
|
||||
*/
|
||||
class ConnectionManager(
|
||||
private val prefs: SecurePrefs,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
@@ -28,13 +25,9 @@ class ConnectionManager(
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val installedAppsSharingEnabled: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Decide whether a discovered/manual endpoint must use pinned TLS or can stay local cleartext.
|
||||
*/
|
||||
internal fun resolveTlsParamsForEndpoint(
|
||||
endpoint: GatewayEndpoint,
|
||||
storedFingerprint: String?,
|
||||
@@ -51,7 +44,6 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
if (isManual) {
|
||||
// Manual remote hosts default to TLS; only local manual hosts may honor the cleartext toggle.
|
||||
if (!manualTlsEnabled && cleartextAllowedHost) return null
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
@@ -91,7 +83,6 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
if (!cleartextAllowedHost) {
|
||||
// Non-loopback discovered hosts require TLS even without TXT hints.
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
@@ -116,19 +107,13 @@ class ConnectionManager(
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
installedAppsSharingEnabled = installedAppsSharingEnabled(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
/** Builds the gateway-advertised node.invoke command list from current permission and feature state. */
|
||||
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
|
||||
|
||||
/** Builds the gateway-advertised capability list from current permission and feature state. */
|
||||
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
|
||||
|
||||
/**
|
||||
* Debug Android builds advertise a dev version so gateway logs do not look like release clients.
|
||||
*/
|
||||
fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
@@ -138,16 +123,12 @@ class ConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable Android device model used in gateway client metadata. */
|
||||
fun resolveModelIdentifier(): String? =
|
||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
|
||||
/**
|
||||
* User-Agent used for gateway telemetry and troubleshooting.
|
||||
*/
|
||||
fun buildUserAgent(): String {
|
||||
val version = resolvedVersionName()
|
||||
val release =
|
||||
@@ -158,7 +139,6 @@ class ConnectionManager(
|
||||
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
|
||||
}
|
||||
|
||||
/** Client identity block shared by node and operator gateway sessions. */
|
||||
fun buildClientInfo(
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
@@ -174,7 +154,6 @@ class ConnectionManager(
|
||||
modelIdentifier = resolveModelIdentifier(),
|
||||
)
|
||||
|
||||
/** Connect options for the Android node session that exposes phone capabilities. */
|
||||
fun buildNodeConnectOptions(): GatewayConnectOptions =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
@@ -186,7 +165,6 @@ class ConnectionManager(
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
|
||||
/** Connect options for the Android operator session that drives approvals and UI actions. */
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions =
|
||||
GatewayConnectOptions(
|
||||
role = "operator",
|
||||
@@ -203,7 +181,6 @@ class ConnectionManager(
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
|
||||
/** Resolves persisted TLS pin policy for a concrete gateway endpoint. */
|
||||
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
|
||||
|
||||
@@ -17,9 +17,6 @@ import kotlinx.serialization.json.put
|
||||
|
||||
private const val DEFAULT_CONTACTS_LIMIT = 25
|
||||
|
||||
/**
|
||||
* Normalized Android contact row returned through the contacts commands.
|
||||
*/
|
||||
internal data class ContactRecord(
|
||||
val identifier: String,
|
||||
val displayName: String,
|
||||
@@ -30,17 +27,11 @@ internal data class ContactRecord(
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed contacts.search request with bounded result count.
|
||||
*/
|
||||
internal data class ContactsSearchRequest(
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed contacts.add request before ContentProviderOperation batching.
|
||||
*/
|
||||
internal data class ContactsAddRequest(
|
||||
val givenName: String?,
|
||||
val familyName: String?,
|
||||
@@ -50,9 +41,6 @@ internal data class ContactsAddRequest(
|
||||
val emails: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Injectable ContactsProvider facade for command tests and Android runtime access.
|
||||
*/
|
||||
internal interface ContactsDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
@@ -94,7 +82,6 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
} else {
|
||||
// Escape wildcard characters so user text remains a substring search, not a LIKE pattern.
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'"
|
||||
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
|
||||
}
|
||||
@@ -132,7 +119,6 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
|
||||
.build()
|
||||
// Subsequent Data rows use back-reference 0 to attach to the RawContact inserted above.
|
||||
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
|
||||
operations +=
|
||||
ContentProviderOperation
|
||||
@@ -182,7 +168,6 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
rawContactUri.lastPathSegment?.toLongOrNull()
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
val contactId =
|
||||
// Android returns the RawContact id; resolve the aggregate Contact id used by search APIs.
|
||||
resolveContactIdForRawContact(resolver, rawContactId)
|
||||
?: throw IllegalStateException("contact insert failed")
|
||||
return loadContactRecord(
|
||||
@@ -345,16 +330,12 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles contacts.search and contacts.add gateway commands through Android ContactsProvider.
|
||||
*/
|
||||
class ContactsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: ContactsDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
|
||||
|
||||
/** Searches contacts by optional display-name substring with bounded result count. */
|
||||
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasReadPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
@@ -388,7 +369,6 @@ class ContactsHandler private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a local contact after validating that at least one user-visible field is present. */
|
||||
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasWritePermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
@@ -438,7 +418,6 @@ class ContactsHandler private constructor(
|
||||
null
|
||||
} ?: return null
|
||||
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
|
||||
// Keep gateway-driven searches bounded even if the model asks for a large contact dump.
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
|
||||
return ContactsSearchRequest(query = query, limit = limit)
|
||||
}
|
||||
@@ -456,7 +435,6 @@ class ContactsHandler private constructor(
|
||||
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
|
||||
// Store emails case-normalized so repeated model calls do not create casing-only duplicates.
|
||||
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
|
||||
)
|
||||
}
|
||||
@@ -480,7 +458,6 @@ class ContactsHandler private constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Creates a handler with an injected contacts source for parser and payload tests. */
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: ContactsDataSource,
|
||||
|
||||
@@ -3,26 +3,23 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val LOGCAT_PATH = "/system/bin/logcat"
|
||||
private const val LOGCAT_TIMEOUT_MS = 4_000L
|
||||
private const val LOGCAT_MAX_CHARS = 128_000
|
||||
|
||||
/**
|
||||
* Debug-only node.invoke commands for Android cryptography and log diagnostics.
|
||||
*/
|
||||
class DebugHandler(
|
||||
private val appContext: Context,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
) {
|
||||
/**
|
||||
* Runs an Ed25519 self-test and returns redacted diagnostics for debug builds.
|
||||
*/
|
||||
fun handleEd25519(): GatewaySession.InvokeResult {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
|
||||
}
|
||||
// Self-test Ed25519 signing without returning full private/public key material.
|
||||
// Self-test Ed25519 signing and return diagnostic info
|
||||
try {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}"
|
||||
@@ -31,14 +28,15 @@ class DebugHandler(
|
||||
results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...")
|
||||
results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...")
|
||||
|
||||
// Public-key URL encoding must match the gateway device-auth payload contract.
|
||||
// Test publicKeyBase64Url
|
||||
val pubKeyUrl = identityStore.publicKeyBase64Url(identity)
|
||||
results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}")
|
||||
|
||||
// Sign/verify through DeviceIdentityStore to catch provider and key-format failures together.
|
||||
// Test signing
|
||||
val signature = identityStore.signPayload(testPayload, identity)
|
||||
results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}")
|
||||
|
||||
// Test self-verify
|
||||
if (signature != null) {
|
||||
val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity)
|
||||
results.add("verifySelfSignature: $verifyOk")
|
||||
@@ -79,9 +77,6 @@ class DebugHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a filtered logcat snapshot plus CameraX debug log for debug builds.
|
||||
*/
|
||||
fun handleLogs(): GatewaySession.InvokeResult {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
|
||||
@@ -89,24 +84,14 @@ class DebugHandler(
|
||||
val pid = android.os.Process.myPid()
|
||||
val rt = Runtime.getRuntime()
|
||||
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
|
||||
// Capture only this process and redirect through a temp file to avoid blocking on pipe backpressure.
|
||||
// Run logcat on current dispatcher thread; output is bounded by -t and never staged to disk.
|
||||
val logResult =
|
||||
try {
|
||||
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
|
||||
if (tmpFile.exists()) tmpFile.delete()
|
||||
val pb = ProcessBuilder(LOGCAT_PATH, "-d", "-t", "200", "--pid=$pid")
|
||||
pb.redirectOutput(tmpFile)
|
||||
pb.redirectErrorStream(true)
|
||||
val proc = pb.start()
|
||||
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!finished) proc.destroyForcibly()
|
||||
val raw =
|
||||
if (tmpFile.exists() && tmpFile.length() > 0) {
|
||||
tmpFile.readText().take(128000)
|
||||
} else {
|
||||
"(no output, finished=$finished, exists=${tmpFile.exists()})"
|
||||
}
|
||||
tmpFile.delete()
|
||||
val (finished, raw) = collectProcessOutput(proc, LOGCAT_TIMEOUT_MS, LOGCAT_MAX_CHARS)
|
||||
val normalizedRaw = raw.ifBlank { "(no output, finished=$finished)" }
|
||||
val spamPatterns =
|
||||
listOf(
|
||||
"setRequestedFrameRate",
|
||||
@@ -127,29 +112,65 @@ class DebugHandler(
|
||||
"IncorrectContextUseViolation",
|
||||
)
|
||||
val sb = StringBuilder()
|
||||
for (line in raw.lineSequence()) {
|
||||
for (line in normalizedRaw.lineSequence()) {
|
||||
if (line.isBlank()) continue
|
||||
if (spamPatterns.any { line.contains(it) }) continue
|
||||
if (sb.length + line.length > 16000) {
|
||||
// Keep debug.invoke responses small enough for the gateway WebSocket frame budget.
|
||||
sb.append("\n(truncated)")
|
||||
break
|
||||
}
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(line)
|
||||
}
|
||||
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
|
||||
sb.toString().ifEmpty { "(all ${normalizedRaw.lines().size} lines filtered as spam)" }
|
||||
} catch (e: Throwable) {
|
||||
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
|
||||
}
|
||||
// Camera capture writes a separate debug file because CameraX failures often happen off logcat's hot path.
|
||||
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
|
||||
val camLog =
|
||||
if (camLogFile.exists() && camLogFile.length() > 0) {
|
||||
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""")
|
||||
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult)}}""")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun collectProcessOutput(
|
||||
process: Process,
|
||||
timeoutMs: Long,
|
||||
maxChars: Int,
|
||||
): Pair<Boolean, String> {
|
||||
val output = AtomicReference("")
|
||||
val failure = AtomicReference<Throwable?>(null)
|
||||
val reader =
|
||||
Thread({
|
||||
try {
|
||||
output.set(readBoundedText(process.inputStream, maxChars))
|
||||
} catch (error: Throwable) {
|
||||
failure.set(error)
|
||||
}
|
||||
}, "openclaw-debug-output-reader")
|
||||
reader.isDaemon = true
|
||||
reader.start()
|
||||
|
||||
val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
if (!finished) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
reader.join(1_000)
|
||||
failure.get()?.let { throw it }
|
||||
return finished to output.get()
|
||||
}
|
||||
|
||||
private fun readBoundedText(
|
||||
stream: InputStream,
|
||||
maxChars: Int,
|
||||
): String =
|
||||
stream.bufferedReader().use { reader ->
|
||||
val out = StringBuilder(minOf(maxChars, 8192))
|
||||
val buffer = CharArray(4096)
|
||||
while (true) {
|
||||
val read = reader.read(buffer)
|
||||
if (read < 0) break
|
||||
val remaining = maxChars - out.length
|
||||
if (remaining > 0) {
|
||||
out.append(buffer, 0, minOf(read, remaining))
|
||||
}
|
||||
}
|
||||
out.toString()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -25,124 +24,13 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.Locale
|
||||
|
||||
private const val DEFAULT_DEVICE_APPS_LIMIT = 100
|
||||
private const val MAX_DEVICE_APPS_LIMIT = 200
|
||||
private const val DEVICE_APPS_SYSTEM_FLAGS =
|
||||
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
|
||||
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean =
|
||||
(appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
|
||||
|
||||
internal data class DeviceAppEntry(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val system: Boolean,
|
||||
val enabled: Boolean,
|
||||
val launchable: Boolean,
|
||||
)
|
||||
|
||||
internal interface DeviceAppSource {
|
||||
fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry>
|
||||
}
|
||||
|
||||
private class AndroidDeviceAppSource(
|
||||
private val appContext: Context,
|
||||
) : DeviceAppSource {
|
||||
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
|
||||
val packageManager = appContext.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
val launchablePackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toSet()
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
return appInfos
|
||||
.asSequence()
|
||||
.mapNotNull { appInfo ->
|
||||
appInfo.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { packageName ->
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
DeviceAppEntry(
|
||||
label = label.ifEmpty { packageName },
|
||||
packageName = packageName,
|
||||
system = isSystemDeviceApp(appInfo),
|
||||
enabled = appInfo.enabled,
|
||||
launchable = packageName in launchablePackages,
|
||||
)
|
||||
}
|
||||
}.distinctBy { it.packageName }
|
||||
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private data class DeviceAppsRequest(
|
||||
val includeSystem: Boolean,
|
||||
val includeDisabled: Boolean,
|
||||
val includeNonLaunchable: Boolean,
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gateway device command adapter for Android status, info, permission, and health snapshots.
|
||||
*/
|
||||
class DeviceHandler private constructor(
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext),
|
||||
) {
|
||||
constructor(
|
||||
appContext: Context,
|
||||
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
) : this(
|
||||
appContext = appContext,
|
||||
smsEnabled = smsEnabled,
|
||||
callLogEnabled = callLogEnabled,
|
||||
photosEnabled = photosEnabled,
|
||||
appSource = AndroidDeviceAppSource(appContext),
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
appSource: DeviceAppSource,
|
||||
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
): DeviceHandler =
|
||||
DeviceHandler(
|
||||
appContext = appContext,
|
||||
smsEnabled = smsEnabled,
|
||||
callLogEnabled = callLogEnabled,
|
||||
photosEnabled = photosEnabled,
|
||||
appSource = appSource,
|
||||
)
|
||||
|
||||
/**
|
||||
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
|
||||
*/
|
||||
internal fun hasAnySmsCapability(
|
||||
smsEnabled: Boolean,
|
||||
telephonyAvailable: Boolean,
|
||||
@@ -150,9 +38,6 @@ class DeviceHandler private constructor(
|
||||
smsReadGranted: Boolean,
|
||||
): Boolean = smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
|
||||
|
||||
/**
|
||||
* Prompt only when Android can grant a missing SMS permission that this build can use.
|
||||
*/
|
||||
internal fun isSmsPromptable(
|
||||
smsEnabled: Boolean,
|
||||
telephonyAvailable: Boolean,
|
||||
@@ -168,60 +53,14 @@ class DeviceHandler private constructor(
|
||||
val temperatureC: Double?,
|
||||
)
|
||||
|
||||
/** Returns battery, storage, network, and uptime state for device.status. */
|
||||
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(statusPayloadJson())
|
||||
|
||||
/** Returns stable Android hardware, OS, app, and locale metadata for device.info. */
|
||||
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(infoPayloadJson())
|
||||
|
||||
/** Returns permission and promptability state for Android capabilities exposed to the gateway. */
|
||||
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(permissionsPayloadJson())
|
||||
|
||||
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
|
||||
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
|
||||
|
||||
fun handleDeviceApps(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val request = parseDeviceAppsRequest(paramsJson)
|
||||
val matchingApps =
|
||||
appSource
|
||||
.listApps(includeNonLaunchable = request.includeNonLaunchable)
|
||||
.asSequence()
|
||||
.filter { request.includeSystem || !it.system }
|
||||
.filter { request.includeDisabled || it.enabled }
|
||||
.filter { app ->
|
||||
val query = request.query ?: return@filter true
|
||||
app.label.contains(query, ignoreCase = true) || app.packageName.contains(query, ignoreCase = true)
|
||||
}.toList()
|
||||
val limitedApps = matchingApps.take(request.limit)
|
||||
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("count", JsonPrimitive(limitedApps.size))
|
||||
put("totalMatched", JsonPrimitive(matchingApps.size))
|
||||
put("truncated", JsonPrimitive(matchingApps.size > limitedApps.size))
|
||||
put("visibility", JsonPrimitive(if (request.includeNonLaunchable) "android-visible" else "launcher"))
|
||||
put("includeSystem", JsonPrimitive(request.includeSystem))
|
||||
put("includeDisabled", JsonPrimitive(request.includeDisabled))
|
||||
put(
|
||||
"apps",
|
||||
buildJsonArray {
|
||||
for (app in limitedApps) {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("label", JsonPrimitive(app.label))
|
||||
put("packageName", JsonPrimitive(app.packageName))
|
||||
put("system", JsonPrimitive(app.system))
|
||||
put("enabled", JsonPrimitive(app.enabled))
|
||||
put("launchable", JsonPrimitive(app.launchable))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
@@ -232,7 +71,6 @@ class DeviceHandler private constructor(
|
||||
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
|
||||
val activeNetwork = connectivity?.activeNetwork
|
||||
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
|
||||
// elapsedRealtime is monotonic device uptime, not wall-clock time.
|
||||
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
|
||||
|
||||
return buildJsonObject {
|
||||
@@ -316,7 +154,6 @@ class DeviceHandler private constructor(
|
||||
if (!photosEnabled) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= 33) {
|
||||
// Android 13 split media permissions; earlier versions use external storage.
|
||||
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
@@ -324,7 +161,6 @@ class DeviceHandler private constructor(
|
||||
val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
val notificationsGranted =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
// POST_NOTIFICATIONS exists only on Android 13+.
|
||||
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
true
|
||||
@@ -459,7 +295,6 @@ class DeviceHandler private constructor(
|
||||
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
|
||||
null
|
||||
} else {
|
||||
// BatteryManager reports microamps; expose milliamps in the gateway payload.
|
||||
currentNowUa.toDouble() / 1_000.0
|
||||
}
|
||||
|
||||
@@ -513,26 +348,7 @@ class DeviceHandler private constructor(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseDeviceAppsRequest(paramsJson: String?): DeviceAppsRequest {
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val includeSystem = parseJsonBooleanFlag(params, "includeSystem") ?: false
|
||||
val includeDisabled = parseJsonBooleanFlag(params, "includeDisabled") ?: false
|
||||
val includeNonLaunchable = parseJsonBooleanFlag(params, "includeNonLaunchable") ?: false
|
||||
val query = parseJsonString(params, "query")?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val limit =
|
||||
(parseJsonInt(params, "limit") ?: DEFAULT_DEVICE_APPS_LIMIT)
|
||||
.coerceIn(1, MAX_DEVICE_APPS_LIMIT)
|
||||
return DeviceAppsRequest(
|
||||
includeSystem = includeSystem,
|
||||
includeDisabled = includeDisabled,
|
||||
includeNonLaunchable = includeNonLaunchable,
|
||||
query = query,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBatterySnapshot(): BatterySnapshot {
|
||||
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
|
||||
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
val status =
|
||||
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
|
||||
@@ -594,7 +410,6 @@ class DeviceHandler private constructor(
|
||||
if (caps == null) return "unsatisfied"
|
||||
return when {
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
|
||||
// Internet without validation mirrors iOS "requiresConnection" for captive or unproven networks.
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
|
||||
else -> "unsatisfied"
|
||||
}
|
||||
@@ -621,7 +436,6 @@ class DeviceHandler private constructor(
|
||||
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
|
||||
if (lowMemory) return "critical"
|
||||
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
|
||||
// Thresholds intentionally mirror coarse OS health labels instead of exact memory pressure.
|
||||
return when {
|
||||
freeRatio <= 0.05 -> "critical"
|
||||
freeRatio <= 0.15 -> "high"
|
||||
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.NotificationBurstLimiter
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import ai.openclaw.app.allowsPackage
|
||||
import ai.openclaw.app.gateway.OpenClawSQLiteStateStore
|
||||
import ai.openclaw.app.isWithinQuietHours
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
@@ -12,7 +13,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -21,18 +21,11 @@ import kotlinx.serialization.json.put
|
||||
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
|
||||
private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed"
|
||||
|
||||
/**
|
||||
* Trims notification text and caps payload size before it enters gateway-visible state.
|
||||
*/
|
||||
internal fun sanitizeNotificationText(value: CharSequence?): String? {
|
||||
val normalized = value?.toString()?.trim().orEmpty()
|
||||
// Notification extras can include long previews; cap before sending over node events.
|
||||
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable notification snapshot entry exposed through the Android notifications command.
|
||||
*/
|
||||
data class DeviceNotificationEntry(
|
||||
val key: String,
|
||||
val packageName: String,
|
||||
@@ -60,36 +53,24 @@ internal fun DeviceNotificationEntry.toJsonObject(): JsonObject =
|
||||
channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener state exposed to the gateway, including whether Android has connected the service.
|
||||
*/
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
val notifications: List<DeviceNotificationEntry>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gateway-supported notification actions mapped to Android listener operations.
|
||||
*/
|
||||
enum class NotificationActionKind {
|
||||
Open,
|
||||
Dismiss,
|
||||
Reply,
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway action request; [key] must match Android's StatusBarNotification key.
|
||||
*/
|
||||
data class NotificationActionRequest(
|
||||
val key: String,
|
||||
val kind: NotificationActionKind,
|
||||
val replyText: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Normalized notification action result returned through node.invoke.
|
||||
*/
|
||||
data class NotificationActionResult(
|
||||
val ok: Boolean,
|
||||
val code: String? = null,
|
||||
@@ -98,9 +79,6 @@ data class NotificationActionResult(
|
||||
|
||||
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean = kind == NotificationActionKind.Dismiss
|
||||
|
||||
/**
|
||||
* Process-local cache of active notifications mirrored from Android listener callbacks.
|
||||
*/
|
||||
private object DeviceNotificationStore {
|
||||
private val lock = Any()
|
||||
private var connected = false
|
||||
@@ -131,7 +109,6 @@ private object DeviceNotificationStore {
|
||||
synchronized(lock) {
|
||||
connected = value
|
||||
if (!value) {
|
||||
// Android invalidates activeNotifications when the listener disconnects.
|
||||
byKey.clear()
|
||||
}
|
||||
}
|
||||
@@ -150,9 +127,6 @@ private object DeviceNotificationStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android notification listener that mirrors notification state and executes gateway actions.
|
||||
*/
|
||||
class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
private val securePrefs by lazy { SecurePrefs(applicationContext) }
|
||||
private val forwardingLimiter = NotificationBurstLimiter()
|
||||
@@ -252,7 +226,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
|
||||
return null
|
||||
}
|
||||
// Apply burst limits after package/quiet-hour filters so blocked notifications do not consume quota.
|
||||
if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
|
||||
return null
|
||||
}
|
||||
@@ -305,8 +278,9 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
|
||||
private const val legacyRecentPackagesPref = "notifications.recentPackages"
|
||||
private const val notificationsPrefsPrefix = "notifications."
|
||||
private const val recentPackagesPref = notificationsPrefsPrefix + "forwarding.recentPackages"
|
||||
private const val legacyRecentPackagesPref = notificationsPrefsPrefix + "recentPackages"
|
||||
private const val recentPackagesLimit = 64
|
||||
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
@@ -315,60 +289,67 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
|
||||
private fun serviceComponent(context: Context): ComponentName = ComponentName(context, DeviceNotificationListenerService::class.java)
|
||||
|
||||
/** Installs the node event sink used to emit filtered notification change events. */
|
||||
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
|
||||
nodeEventSink = sink
|
||||
}
|
||||
|
||||
private fun recentPackagesPrefs(context: Context) = context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
private fun recentPackagesPrefs(context: Context) =
|
||||
context.applicationContext
|
||||
.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
|
||||
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
|
||||
private fun migrateLegacyRecentPackagesIfNeeded(
|
||||
context: Context,
|
||||
stateStore: OpenClawSQLiteStateStore,
|
||||
): List<String> {
|
||||
val prefs = recentPackagesPrefs(context)
|
||||
val hasNew = prefs.contains(recentPackagesPref)
|
||||
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
|
||||
if (!hasNew && legacy.isNotEmpty()) {
|
||||
// Keep recent package suggestions across the preference-key rename.
|
||||
prefs.edit {
|
||||
putString(recentPackagesPref, legacy)
|
||||
remove(legacyRecentPackagesPref)
|
||||
}
|
||||
} else if (hasNew && prefs.contains(legacyRecentPackagesPref)) {
|
||||
prefs.edit { remove(legacyRecentPackagesPref) }
|
||||
val raw =
|
||||
prefs.getString(recentPackagesPref, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
|
||||
val packages =
|
||||
raw
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(recentPackagesLimit)
|
||||
if (packages.isNotEmpty()) {
|
||||
stateStore.replaceRecentNotificationPackages(packages, recentPackagesLimit)
|
||||
}
|
||||
if (prefs.contains(recentPackagesPref) || prefs.contains(legacyRecentPackagesPref)) {
|
||||
prefs
|
||||
.edit()
|
||||
.remove(recentPackagesPref)
|
||||
.remove(legacyRecentPackagesPref)
|
||||
.apply()
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
/** Returns recent third-party packages seen by the listener for settings suggestions. */
|
||||
fun recentPackages(context: Context): List<String> {
|
||||
migrateLegacyRecentPackagesIfNeeded(context)
|
||||
val prefs = recentPackagesPrefs(context)
|
||||
val stored = prefs.getString(recentPackagesPref, null).orEmpty()
|
||||
return stored
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
val stateStore = OpenClawSQLiteStateStore(context)
|
||||
val stored = stateStore.readRecentNotificationPackages(recentPackagesLimit)
|
||||
if (stored.isNotEmpty()) {
|
||||
return stored
|
||||
}
|
||||
return migrateLegacyRecentPackagesIfNeeded(context, stateStore)
|
||||
}
|
||||
|
||||
/** Checks whether Android has granted listener access to this service component. */
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
|
||||
}
|
||||
|
||||
/** Reads the current mirrored notification snapshot without forcing service startup. */
|
||||
fun snapshot(
|
||||
context: Context,
|
||||
enabled: Boolean = isAccessEnabled(context),
|
||||
): DeviceNotificationSnapshot = DeviceNotificationStore.snapshot(enabled = enabled)
|
||||
|
||||
/** Asks Android to rebind the listener after settings grant access but callbacks have not arrived. */
|
||||
fun requestServiceRebind(context: Context) {
|
||||
runCatching {
|
||||
NotificationListenerService.requestRebind(serviceComponent(context))
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes an open, dismiss, or reply action through the active listener instance. */
|
||||
fun executeAction(
|
||||
context: Context,
|
||||
request: NotificationActionRequest,
|
||||
@@ -400,19 +381,13 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
val service = activeService ?: return
|
||||
val normalized = packageName?.trim().orEmpty()
|
||||
if (normalized.isEmpty() || normalized == service.packageName) return
|
||||
migrateLegacyRecentPackagesIfNeeded(service.applicationContext)
|
||||
val prefs = recentPackagesPrefs(service.applicationContext)
|
||||
val existing =
|
||||
prefs
|
||||
.getString(recentPackagesPref, null)
|
||||
.orEmpty()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != normalized }
|
||||
recentPackages(service.applicationContext)
|
||||
.filter { it != normalized }
|
||||
.take(recentPackagesLimit - 1)
|
||||
// Most recent package first keeps settings suggestions useful without storing notification content.
|
||||
val updated = listOf(normalized) + existing
|
||||
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
|
||||
OpenClawSQLiteStateStore(service.applicationContext)
|
||||
.replaceRecentNotificationPackages(updated, recentPackagesLimit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +459,6 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
val action =
|
||||
sbn.notification.actions
|
||||
?.firstOrNull { candidate ->
|
||||
// Android reply actions are identified by RemoteInput, not by a stable action title.
|
||||
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
|
||||
}
|
||||
?: return NotificationActionResult(
|
||||
|
||||
@@ -9,9 +9,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
/**
|
||||
* Handles gateway-originated events that need to update local Android preferences.
|
||||
*/
|
||||
class GatewayEventHandler(
|
||||
private val scope: CoroutineScope,
|
||||
private val prefs: SecurePrefs,
|
||||
@@ -22,14 +19,12 @@ class GatewayEventHandler(
|
||||
private var suppressWakeWordsSync = false
|
||||
private var wakeWordsSyncJob: Job? = null
|
||||
|
||||
/** Applies gateway wake words locally without echoing the same change back to the gateway. */
|
||||
fun applyWakeWordsFromGateway(words: List<String>) {
|
||||
suppressWakeWordsSync = true
|
||||
prefs.setWakeWords(words)
|
||||
suppressWakeWordsSync = false
|
||||
}
|
||||
|
||||
/** Debounces local wake-word edits before sending voicewake.set to the operator session. */
|
||||
fun scheduleWakeWordsSyncIfNeeded() {
|
||||
if (suppressWakeWordsSync) return
|
||||
if (!isConnected()) return
|
||||
@@ -49,7 +44,6 @@ class GatewayEventHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads gateway wake words on connect so Android settings show server truth. */
|
||||
suspend fun refreshWakeWordsFromGateway() {
|
||||
if (!isConnected()) return
|
||||
try {
|
||||
@@ -63,7 +57,6 @@ class GatewayEventHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies voicewake.changed event payloads emitted by the gateway. */
|
||||
fun handleVoiceWakeChangedEvent(payloadJson: String?) {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawSystemCommand
|
||||
import ai.openclaw.app.protocol.OpenClawTalkCommand
|
||||
|
||||
/** Runtime feature flags used to decide which node tools are advertised. */
|
||||
data class NodeRuntimeFlags(
|
||||
val cameraEnabled: Boolean,
|
||||
val locationEnabled: Boolean,
|
||||
@@ -28,11 +27,9 @@ data class NodeRuntimeFlags(
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
val installedAppsSharingEnabled: Boolean,
|
||||
val debugBuild: Boolean,
|
||||
)
|
||||
|
||||
/** Per-command availability gates checked before advertising invoke methods. */
|
||||
enum class InvokeCommandAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
@@ -44,11 +41,9 @@ enum class InvokeCommandAvailability {
|
||||
PhotosAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
InstalledAppsSharingEnabled,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
/** Per-capability availability gates for the node capabilities manifest. */
|
||||
enum class NodeCapabilityAvailability {
|
||||
Always,
|
||||
CameraEnabled,
|
||||
@@ -60,13 +55,11 @@ enum class NodeCapabilityAvailability {
|
||||
MotionAvailable,
|
||||
}
|
||||
|
||||
/** Capability entry reported to the gateway when its availability gate passes. */
|
||||
data class NodeCapabilitySpec(
|
||||
val name: String,
|
||||
val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always,
|
||||
)
|
||||
|
||||
/** Invoke method entry advertised to gateway plus foreground routing metadata. */
|
||||
data class InvokeCommandSpec(
|
||||
val name: String,
|
||||
val requiresForeground: Boolean = false,
|
||||
@@ -74,7 +67,6 @@ data class InvokeCommandSpec(
|
||||
)
|
||||
|
||||
object InvokeCommandRegistry {
|
||||
/** Capabilities mirror gateway protocol ids and are filtered by device state. */
|
||||
val capabilityManifest: List<NodeCapabilitySpec> =
|
||||
listOf(
|
||||
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
|
||||
@@ -114,7 +106,6 @@ object InvokeCommandRegistry {
|
||||
),
|
||||
)
|
||||
|
||||
/** Complete Android node command catalog before runtime availability filtering. */
|
||||
val all: List<InvokeCommandSpec> =
|
||||
listOf(
|
||||
InvokeCommandSpec(
|
||||
@@ -195,10 +186,6 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Health.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Apps.rawValue,
|
||||
availability = InvokeCommandAvailability.InstalledAppsSharingEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
@@ -253,10 +240,8 @@ object InvokeCommandRegistry {
|
||||
|
||||
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
|
||||
|
||||
/** Finds the command metadata used by dispatch and advertised-method builders. */
|
||||
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
|
||||
|
||||
/** Returns gateway capability ids the current Android device can actually serve. */
|
||||
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> =
|
||||
capabilityManifest
|
||||
.filter { spec ->
|
||||
@@ -272,7 +257,6 @@ object InvokeCommandRegistry {
|
||||
}
|
||||
}.map { it.name }
|
||||
|
||||
/** Returns gateway invoke method ids available under current permissions/build flags. */
|
||||
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> =
|
||||
all
|
||||
.filter { spec ->
|
||||
@@ -287,7 +271,6 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.InstalledAppsSharingEnabled -> flags.installedAppsSharingEnabled
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
}
|
||||
}.map { it.name }
|
||||
|
||||
@@ -15,16 +15,12 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.app.protocol.OpenClawSystemCommand
|
||||
import ai.openclaw.app.protocol.OpenClawTalkCommand
|
||||
|
||||
/** Runtime state for SMS search, split so permission prompts are not reported as hard unavailability. */
|
||||
internal enum class SmsSearchAvailabilityReason {
|
||||
Available,
|
||||
PermissionRequired,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish permanent SMS search unavailability from permission-gated search.
|
||||
*/
|
||||
internal fun classifySmsSearchAvailability(
|
||||
readSmsAvailable: Boolean,
|
||||
smsFeatureEnabled: Boolean,
|
||||
@@ -57,9 +53,6 @@ internal fun smsSearchAvailabilityError(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway node.invoke command router for Android-owned capabilities.
|
||||
*/
|
||||
class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
private val cameraHandler: CameraHandler,
|
||||
@@ -85,7 +78,6 @@ class InvokeDispatcher(
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val installedAppsSharingEnabled: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
@@ -93,7 +85,6 @@ class InvokeDispatcher(
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
) {
|
||||
/** Dispatches one gateway node.invoke command after foreground and availability gates pass. */
|
||||
suspend fun handleInvoke(
|
||||
command: String,
|
||||
paramsJson: String?,
|
||||
@@ -105,7 +96,6 @@ class InvokeDispatcher(
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
if (spec.requiresForeground && !isForeground()) {
|
||||
// Canvas, camera, and screen-backed commands need an active Activity/WebView surface.
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
@@ -113,7 +103,6 @@ class InvokeDispatcher(
|
||||
}
|
||||
availabilityError(spec.availability)?.let { return it }
|
||||
|
||||
// Command strings come from OpenClawProtocolConstants; the registry above owns advertised availability.
|
||||
return when (command) {
|
||||
// Canvas commands
|
||||
OpenClawCanvasCommand.Present.rawValue -> {
|
||||
@@ -194,7 +183,6 @@ class InvokeDispatcher(
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
|
||||
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
|
||||
OpenClawDeviceCommand.Apps.rawValue -> deviceHandler.handleDeviceApps(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
@@ -251,7 +239,6 @@ class InvokeDispatcher(
|
||||
)
|
||||
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!readyOnFirstCheck) {
|
||||
// Gateway canvas host metadata can lag reconnects; refresh once before failing the command.
|
||||
refreshCanvasHostUrl()
|
||||
a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: a2uiUrl
|
||||
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
|
||||
@@ -268,7 +255,6 @@ class InvokeDispatcher(
|
||||
try {
|
||||
block()
|
||||
} catch (_: Throwable) {
|
||||
// WebView calls throw when the Activity is backgrounded between the foreground check and execution.
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
@@ -326,7 +312,6 @@ class InvokeDispatcher(
|
||||
InvokeCommandAvailability.ReadSmsAvailable,
|
||||
InvokeCommandAvailability.RequestableSmsSearchAvailable,
|
||||
->
|
||||
// SMS search may still be advertised as promptable; runtime invoke fails only on permanent unavailability.
|
||||
smsSearchAvailabilityError(
|
||||
readSmsAvailable = readSmsAvailable(),
|
||||
smsFeatureEnabled = smsFeatureEnabled(),
|
||||
@@ -350,15 +335,6 @@ class InvokeDispatcher(
|
||||
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.InstalledAppsSharingEnabled ->
|
||||
if (installedAppsSharingEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message = "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
@@ -371,19 +347,12 @@ class InvokeDispatcher(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Talk-mode command adapter implemented by the voice subsystem.
|
||||
*/
|
||||
interface TalkHandler {
|
||||
/** Starts a push-to-talk capture session and keeps it open until stop or cancel. */
|
||||
suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult
|
||||
|
||||
/** Finishes the active push-to-talk capture and submits recognized speech. */
|
||||
suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult
|
||||
|
||||
/** Aborts the active push-to-talk capture without submitting speech. */
|
||||
suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult
|
||||
|
||||
/** Runs a bounded one-shot push-to-talk capture. */
|
||||
suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Result of a JPEG compression attempt after quality and scale reductions.
|
||||
*/
|
||||
internal data class JpegSizeLimiterResult(
|
||||
val bytes: ByteArray,
|
||||
val width: Int,
|
||||
@@ -14,11 +11,7 @@ internal data class JpegSizeLimiterResult(
|
||||
val quality: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Utility that searches quality/scale combinations until a JPEG fits a byte budget.
|
||||
*/
|
||||
internal object JpegSizeLimiter {
|
||||
/** Compresses with the caller-provided encoder, reducing quality before image dimensions. */
|
||||
fun compressToLimit(
|
||||
initialWidth: Int,
|
||||
initialHeight: Int,
|
||||
|
||||
@@ -14,9 +14,6 @@ import kotlinx.coroutines.withTimeout
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Android LocationManager-backed capture used by gateway location commands.
|
||||
*/
|
||||
class LocationCaptureManager(
|
||||
private val context: Context,
|
||||
) {
|
||||
@@ -38,7 +35,6 @@ class LocationCaptureManager(
|
||||
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
|
||||
}
|
||||
|
||||
// Prefer a recent cached fix before waking GPS/network providers.
|
||||
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
|
||||
val location =
|
||||
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
|
||||
@@ -85,7 +81,6 @@ class LocationCaptureManager(
|
||||
val candidates =
|
||||
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||
val freshest = candidates.maxByOrNull { it.time } ?: return null
|
||||
// maxAgeMs is a caller contract; stale cached fixes force a live provider request.
|
||||
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
|
||||
return freshest
|
||||
}
|
||||
@@ -107,7 +102,6 @@ class LocationCaptureManager(
|
||||
val resolved =
|
||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
||||
// getCurrentLocation can return null; the handler maps timeout/null fixes to gateway error shapes.
|
||||
val location =
|
||||
withTimeout(timeoutMs.coerceAtLeast(1)) {
|
||||
suspendCancellableCoroutine<Location?> { cont ->
|
||||
|
||||
@@ -10,9 +10,6 @@ import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Injectable location facade for command tests and Android runtime access.
|
||||
*/
|
||||
internal interface LocationDataSource {
|
||||
fun hasFinePermission(context: Context): Boolean
|
||||
|
||||
@@ -72,14 +69,11 @@ class LocationHandler private constructor(
|
||||
locationPreciseEnabled = locationPreciseEnabled,
|
||||
)
|
||||
|
||||
/** Reports whether precise GPS-backed location can be requested from Android. */
|
||||
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
|
||||
|
||||
/** Reports whether network/coarse location can be requested from Android. */
|
||||
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
|
||||
|
||||
companion object {
|
||||
/** Creates a handler with injected location state for permission and payload tests. */
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: LocationDataSource,
|
||||
@@ -96,10 +90,8 @@ class LocationHandler private constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/** Handles location.get with foreground, permission, and user precision gates applied. */
|
||||
suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!isForeground()) {
|
||||
// Android foreground restrictions and user expectation keep live location tied to the visible app.
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
|
||||
@@ -113,8 +105,6 @@ class LocationHandler private constructor(
|
||||
}
|
||||
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
|
||||
val preciseEnabled = locationPreciseEnabled()
|
||||
// Gateway requests are advisory; Android permission and user settings decide
|
||||
// whether precise capture is actually allowed for this invocation.
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
|
||||
@@ -123,7 +113,6 @@ class LocationHandler private constructor(
|
||||
}
|
||||
val providers =
|
||||
when (accuracy) {
|
||||
// Provider order is part of the accuracy policy: GPS first for precise, network first otherwise.
|
||||
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
@@ -162,7 +151,6 @@ class LocationHandler private constructor(
|
||||
val timeoutMs =
|
||||
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
|
||||
?: 10_000L
|
||||
// desiredAccuracy is advisory; invalid values fall through to the default policy.
|
||||
val desiredAccuracy =
|
||||
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
|
||||
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
|
||||
|
||||
@@ -25,20 +25,17 @@ import kotlin.math.sqrt
|
||||
private const val ACCELEROMETER_SAMPLE_TARGET = 20
|
||||
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
|
||||
|
||||
/** Gateway request for motion.activity after parsing and limit bounds. */
|
||||
internal data class MotionActivityRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
/** Gateway request for motion.pedometer. */
|
||||
internal data class MotionPedometerRequest(
|
||||
val startISO: String?,
|
||||
val endISO: String?,
|
||||
)
|
||||
|
||||
/** Motion activity sample returned in gateway-compatible boolean flags. */
|
||||
internal data class MotionActivityRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
@@ -51,7 +48,6 @@ internal data class MotionActivityRecord(
|
||||
val isUnknown: Boolean,
|
||||
)
|
||||
|
||||
/** Pedometer sample returned from Android's cumulative step counter. */
|
||||
internal data class PedometerRecord(
|
||||
val startISO: String,
|
||||
val endISO: String,
|
||||
@@ -61,7 +57,6 @@ internal data class PedometerRecord(
|
||||
val floorsDescended: Int?,
|
||||
)
|
||||
|
||||
/** Motion data seam for Android sensors and tests. */
|
||||
internal interface MotionDataSource {
|
||||
fun isActivityAvailable(context: Context): Boolean
|
||||
|
||||
@@ -102,8 +97,6 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
request: MotionActivityRequest,
|
||||
): MotionActivityRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
// Android does not expose historical activity samples here; fail with a
|
||||
// stable gateway code instead of pretending the range is empty.
|
||||
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
|
||||
}
|
||||
val sensorManager =
|
||||
@@ -137,7 +130,6 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
request: MotionPedometerRequest,
|
||||
): PedometerRecord {
|
||||
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
|
||||
// TYPE_STEP_COUNTER is cumulative since boot, not a historical query API.
|
||||
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
|
||||
}
|
||||
val sensorManager =
|
||||
@@ -224,8 +216,6 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
|
||||
count += 1
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
|
||||
// Average gravity-adjusted magnitude across a short window so
|
||||
// one noisy sensor event cannot decide the activity label.
|
||||
val result =
|
||||
AccelerometerSample(
|
||||
samples = count,
|
||||
@@ -270,14 +260,12 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles Android motion-related node.invoke commands backed by live sensors. */
|
||||
class MotionHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: MotionDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
|
||||
|
||||
/** Classifies a short accelerometer sample into the gateway activity shape. */
|
||||
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
@@ -325,7 +313,6 @@ class MotionHandler private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the current boot-scoped Android step-counter reading. */
|
||||
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
@@ -363,10 +350,8 @@ class MotionHandler private constructor(
|
||||
|
||||
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
|
||||
|
||||
/** Returns true when live accelerometer classification can be sampled. */
|
||||
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
|
||||
|
||||
/** Returns true when Android exposes a cumulative step-counter sensor. */
|
||||
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
|
||||
|
||||
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
|
||||
@@ -379,8 +364,6 @@ class MotionHandler private constructor(
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return null
|
||||
// Keep the accepted gateway parameter even though Android can only return
|
||||
// one live classification sample for now.
|
||||
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
|
||||
return MotionActivityRequest(
|
||||
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
|
||||
@@ -406,10 +389,8 @@ class MotionHandler private constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Static capability probe used before a MotionHandler instance is needed. */
|
||||
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
|
||||
|
||||
/** Creates a handler with an injected sensor source for parser and payload tests. */
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: MotionDataSource,
|
||||
|
||||
@@ -6,16 +6,10 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
internal object NodePresenceAliveBeacon {
|
||||
/** Gateway event emitted by Android when background execution confirms liveness. */
|
||||
const val EVENT_NAME: String = "node.presence.alive"
|
||||
|
||||
/** Avoids spamming presence when multiple background triggers fire together. */
|
||||
const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000
|
||||
private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024
|
||||
|
||||
/**
|
||||
* Source of the liveness event, serialized as gateway-stable wire values.
|
||||
*/
|
||||
enum class Trigger(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -27,9 +21,6 @@ internal object NodePresenceAliveBeacon {
|
||||
Connect("connect"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal gateway response fields used to decide whether a liveness event was accepted.
|
||||
*/
|
||||
data class ResponsePayload(
|
||||
val ok: Boolean?,
|
||||
val event: String?,
|
||||
@@ -39,7 +30,6 @@ internal object NodePresenceAliveBeacon {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** Skips sends after a recent successful presence update. */
|
||||
fun shouldSkipRecentSuccess(
|
||||
nowMs: Long,
|
||||
lastSuccessAtMs: Long?,
|
||||
@@ -51,7 +41,6 @@ internal object NodePresenceAliveBeacon {
|
||||
return elapsed >= 0 && elapsed < minIntervalMs
|
||||
}
|
||||
|
||||
/** Human-readable Android version label included in presence payloads. */
|
||||
fun androidPlatformLabel(): String {
|
||||
val release =
|
||||
Build.VERSION.RELEASE
|
||||
@@ -61,7 +50,6 @@ internal object NodePresenceAliveBeacon {
|
||||
return "Android $release (SDK ${Build.VERSION.SDK_INT})"
|
||||
}
|
||||
|
||||
/** Builds the compact JSON payload consumed by gateway node-presence handlers. */
|
||||
fun makePayloadJson(
|
||||
trigger: Trigger,
|
||||
sentAtMs: Long,
|
||||
@@ -83,11 +71,8 @@ internal object NodePresenceAliveBeacon {
|
||||
pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) }
|
||||
}.toString()
|
||||
|
||||
/** Parses the gateway response while rejecting empty, oversized, or malformed payloads. */
|
||||
fun decodeResponse(payloadJson: String?): ResponsePayload? {
|
||||
val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
// Bound log/IPC responses before JSON parsing to avoid memory spikes from
|
||||
// malformed gateway replies.
|
||||
if (raw.length > MAX_RESPONSE_JSON_CHARS) return null
|
||||
val obj =
|
||||
try {
|
||||
@@ -103,7 +88,6 @@ internal object NodePresenceAliveBeacon {
|
||||
)
|
||||
}
|
||||
|
||||
/** Sanitizes gateway response reasons before writing them into Android logs. */
|
||||
fun sanitizeReasonForLog(raw: String?): String {
|
||||
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported"
|
||||
return value
|
||||
|
||||
@@ -8,10 +8,8 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/** Default canvas seam color used when gateway/user params omit a hex color. */
|
||||
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
/** Small tuple used by Android node handlers that need four return values. */
|
||||
data class Quad<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
@@ -19,7 +17,6 @@ data class Quad<A, B, C, D>(
|
||||
val fourth: D,
|
||||
)
|
||||
|
||||
/** Escapes a Kotlin string into a JSON string literal without building a JsonElement. */
|
||||
fun String.toJsonString(): String {
|
||||
val escaped =
|
||||
this
|
||||
@@ -32,7 +29,6 @@ fun String.toJsonString(): String {
|
||||
|
||||
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
/** Parses invoke params into a JSON object, returning null for absent/malformed input. */
|
||||
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
@@ -42,31 +38,26 @@ fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a primitive field from invoke params without accepting arrays/objects. */
|
||||
fun readJsonPrimitive(
|
||||
params: JsonObject?,
|
||||
key: String,
|
||||
): JsonPrimitive? = params?.get(key) as? JsonPrimitive
|
||||
|
||||
/** Parses an optional integer invoke param. */
|
||||
fun parseJsonInt(
|
||||
params: JsonObject?,
|
||||
key: String,
|
||||
): Int? = readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
/** Parses an optional decimal invoke param. */
|
||||
fun parseJsonDouble(
|
||||
params: JsonObject?,
|
||||
key: String,
|
||||
): Double? = readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
/** Parses an optional string invoke param. */
|
||||
fun parseJsonString(
|
||||
params: JsonObject?,
|
||||
key: String,
|
||||
): String? = readJsonPrimitive(params, key)?.contentOrNull
|
||||
|
||||
/** Parses strict true/false flags from string-like JSON primitives. */
|
||||
fun parseJsonBooleanFlag(
|
||||
params: JsonObject?,
|
||||
key: String,
|
||||
@@ -79,7 +70,6 @@ fun parseJsonBooleanFlag(
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts JSON null to Kotlin null while preserving primitive text content. */
|
||||
fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
@@ -87,7 +77,6 @@ fun JsonElement?.asStringOrNull(): String? =
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Parses #RRGGBB or RRGGBB into opaque ARGB. */
|
||||
fun parseHexColorArgb(raw: String?): Long? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
@@ -97,18 +86,15 @@ fun parseHexColorArgb(raw: String?): Long? {
|
||||
return 0xFF000000L or rgb
|
||||
}
|
||||
|
||||
/** Converts gateway invocation throwables into protocol code/message pairs. */
|
||||
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
|
||||
}
|
||||
|
||||
/** Normalizes user/session keys while preserving main as the canonical session id. */
|
||||
fun normalizeMainKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
return if (trimmed.isEmpty()) null else trimmed
|
||||
}
|
||||
|
||||
/** Returns true only for the canonical main-session key understood by gateway UI. */
|
||||
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"
|
||||
|
||||
@@ -10,9 +10,6 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Injectable notification listener facade so command parsing can be tested without Android service state.
|
||||
*/
|
||||
internal interface NotificationsStateProvider {
|
||||
fun readSnapshot(context: Context): DeviceNotificationSnapshot
|
||||
|
||||
@@ -25,7 +22,6 @@ internal interface NotificationsStateProvider {
|
||||
}
|
||||
|
||||
private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
/** Reads listener state through Android APIs and returns a disabled snapshot when access is missing. */
|
||||
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
|
||||
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
if (!enabled) {
|
||||
@@ -38,32 +34,27 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
|
||||
return DeviceNotificationListenerService.snapshot(context, enabled = true)
|
||||
}
|
||||
|
||||
/** Requests a platform listener rebind after access has been granted. */
|
||||
override fun requestServiceRebind(context: Context) {
|
||||
DeviceNotificationListenerService.requestServiceRebind(context)
|
||||
}
|
||||
|
||||
/** Delegates actions to the active listener service instance. */
|
||||
override fun executeAction(
|
||||
context: Context,
|
||||
request: NotificationActionRequest,
|
||||
): NotificationActionResult = DeviceNotificationListenerService.executeAction(context, request)
|
||||
}
|
||||
|
||||
/** Handles notification listing and actions via the Android listener service. */
|
||||
class NotificationsHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val stateProvider: NotificationsStateProvider,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
|
||||
|
||||
/** Lists the current listener snapshot after nudging Android to reconnect if needed. */
|
||||
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val snapshot = readSnapshotWithRebind()
|
||||
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
|
||||
}
|
||||
|
||||
/** Executes an action against a notification key from the current listener snapshot. */
|
||||
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
readSnapshotWithRebind()
|
||||
|
||||
@@ -85,8 +76,6 @@ class NotificationsHandler private constructor(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
|
||||
)
|
||||
// Keep accepted action names aligned with the cross-platform notification
|
||||
// command contract rather than Android-specific PendingIntent labels.
|
||||
val action =
|
||||
when (actionRaw) {
|
||||
"open" -> NotificationActionKind.Open
|
||||
@@ -134,7 +123,6 @@ class NotificationsHandler private constructor(
|
||||
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
|
||||
val snapshot = stateProvider.readSnapshot(appContext)
|
||||
if (snapshot.enabled && !snapshot.connected) {
|
||||
// Access can be granted while Android has not rebound the listener yet.
|
||||
stateProvider.requestServiceRebind(appContext)
|
||||
}
|
||||
return snapshot
|
||||
|
||||
@@ -29,14 +29,12 @@ private const val DEFAULT_PHOTOS_QUALITY = 0.85
|
||||
private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024
|
||||
private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024
|
||||
|
||||
/** Request shape for photos.latest after defaults and bounds are applied. */
|
||||
internal data class PhotosLatestRequest(
|
||||
val limit: Int,
|
||||
val maxWidth: Int,
|
||||
val quality: Double,
|
||||
)
|
||||
|
||||
/** Encoded photo payload returned to the gateway. */
|
||||
internal data class EncodedPhotoPayload(
|
||||
val format: String,
|
||||
val base64: String,
|
||||
@@ -45,7 +43,6 @@ internal data class EncodedPhotoPayload(
|
||||
val createdAt: String?,
|
||||
)
|
||||
|
||||
/** Photo access seam for Android MediaStore and tests. */
|
||||
internal interface PhotosDataSource {
|
||||
fun hasPermission(context: Context): Boolean
|
||||
|
||||
@@ -56,7 +53,6 @@ internal interface PhotosDataSource {
|
||||
}
|
||||
|
||||
private object SystemPhotosDataSource : PhotosDataSource {
|
||||
/** Checks the API-specific image read permission used by MediaStore image access. */
|
||||
override fun hasPermission(context: Context): Boolean {
|
||||
val permission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
@@ -81,8 +77,6 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
if (remainingBudget <= 0) break
|
||||
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
|
||||
try {
|
||||
// Enforce both per-photo and total payload budgets before returning
|
||||
// base64 data through the gateway invoke response.
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS)
|
||||
if (encoded == null) continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
@@ -178,8 +172,6 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
} ?: return null
|
||||
|
||||
if (decoded.width <= maxWidth) return decoded
|
||||
// Decode sampling is power-of-two only; finish with exact scaling when the
|
||||
// sampled bitmap is still wider than the requested max width.
|
||||
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
|
||||
return try {
|
||||
decoded.scale(maxWidth, targetHeight, true)
|
||||
@@ -223,7 +215,6 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
)
|
||||
}
|
||||
if (jpegQuality > 35) {
|
||||
// Try quality reduction before resizing so small images keep detail.
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
@@ -241,14 +232,12 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles photos.latest by querying MediaStore and returning bounded JPEG payloads. */
|
||||
class PhotosHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: PhotosDataSource,
|
||||
) {
|
||||
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource)
|
||||
|
||||
/** Returns the newest accessible photos as gateway-sized base64 JPEGs. */
|
||||
fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (!dataSource.hasPermission(appContext)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
@@ -311,7 +300,6 @@ class PhotosHandler private constructor(
|
||||
val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull()
|
||||
|
||||
// Clamp model-supplied values to protect memory and response-size limits.
|
||||
val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20)
|
||||
val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096)
|
||||
val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0)
|
||||
@@ -319,7 +307,6 @@ class PhotosHandler private constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Creates a handler with an injected photo source for parser and payload tests. */
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
dataSource: PhotosDataSource,
|
||||
|
||||
@@ -17,7 +17,6 @@ import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
|
||||
|
||||
/** Parsed payload for system.notify invocations. */
|
||||
internal data class SystemNotifyRequest(
|
||||
val title: String,
|
||||
val body: String,
|
||||
@@ -25,7 +24,6 @@ internal data class SystemNotifyRequest(
|
||||
val priority: String?,
|
||||
)
|
||||
|
||||
/** Notification posting seam used by production Android and unit tests. */
|
||||
internal interface SystemNotificationPoster {
|
||||
fun isAuthorized(): Boolean
|
||||
|
||||
@@ -35,7 +33,6 @@ internal interface SystemNotificationPoster {
|
||||
private class AndroidSystemNotificationPoster(
|
||||
private val appContext: Context,
|
||||
) : SystemNotificationPoster {
|
||||
/** Checks both Android 13 runtime permission and app-level notification enablement. */
|
||||
override fun isAuthorized(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val granted =
|
||||
@@ -46,7 +43,6 @@ private class AndroidSystemNotificationPoster(
|
||||
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
/** Posts through a priority-specific channel so Android's immutable channel importance is respected. */
|
||||
override fun post(request: SystemNotifyRequest) {
|
||||
val channelId = ensureChannel(request.priority)
|
||||
val silent = isSilentSound(request.sound)
|
||||
@@ -73,8 +69,6 @@ private class AndroidSystemNotificationPoster(
|
||||
|
||||
private fun ensureChannel(priority: String?): String {
|
||||
val normalizedPriority = priority.orEmpty().trim().lowercase()
|
||||
// Android channel importance is immutable after creation, so priority maps
|
||||
// to stable channel ids instead of mutating one shared channel.
|
||||
val (suffix, importance, name) =
|
||||
when (normalizedPriority) {
|
||||
"passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive")
|
||||
@@ -103,13 +97,11 @@ private class AndroidSystemNotificationPoster(
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles system-level node.invoke commands implemented by Android services. */
|
||||
class SystemHandler private constructor(
|
||||
private val poster: SystemNotificationPoster,
|
||||
) {
|
||||
constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext))
|
||||
|
||||
/** Posts an Android notification from the gateway system.notify command. */
|
||||
fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val params =
|
||||
parseNotifyRequest(paramsJson)
|
||||
@@ -147,8 +139,6 @@ class SystemHandler private constructor(
|
||||
|
||||
private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? {
|
||||
val params = parseParamsObject(paramsJson) ?: return null
|
||||
// title/body are required by the gateway contract; optional fields only
|
||||
// influence Android channel/silence behavior.
|
||||
val rawTitle =
|
||||
(params["title"] as? JsonPrimitive)
|
||||
?.contentOrNull
|
||||
@@ -177,7 +167,6 @@ class SystemHandler private constructor(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Creates a handler with a fake poster for parser and authorization tests. */
|
||||
internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object OpenClawCanvasA2UIAction {
|
||||
/** Reads the agent-facing action name from either the modern name field or legacy action field. */
|
||||
fun extractActionName(userAction: JsonObject): String? {
|
||||
val name =
|
||||
(userAction["name"] as? JsonPrimitive)
|
||||
@@ -20,7 +19,6 @@ object OpenClawCanvasA2UIAction {
|
||||
return action.ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Normalizes prompt tag values so the compact CANVAS_A2UI envelope stays parser-friendly. */
|
||||
fun sanitizeTagValue(value: String): String {
|
||||
val trimmed = value.trim().ifEmpty { "-" }
|
||||
val normalized = trimmed.replace(" ", "_")
|
||||
@@ -37,7 +35,6 @@ object OpenClawCanvasA2UIAction {
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** Formats the compact text envelope sent to the agent when a canvas UI action fires. */
|
||||
fun formatAgentMessage(
|
||||
actionName: String,
|
||||
sessionKey: String,
|
||||
@@ -60,7 +57,6 @@ object OpenClawCanvasA2UIAction {
|
||||
).joinToString(separator = " ")
|
||||
}
|
||||
|
||||
/** Builds JS that reports an agent action result back to the canvas runtime. */
|
||||
fun jsDispatchA2UIActionStatus(
|
||||
actionId: String,
|
||||
ok: Boolean,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.protocol
|
||||
|
||||
/** Capability ids advertised by the Android node to the OpenClaw gateway. */
|
||||
enum class OpenClawCapability(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -20,7 +19,6 @@ enum class OpenClawCapability(
|
||||
CallLog("callLog"),
|
||||
}
|
||||
|
||||
/** Canvas command ids mirrored from the gateway tool namespace. */
|
||||
enum class OpenClawCanvasCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -36,7 +34,6 @@ enum class OpenClawCanvasCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Streaming canvas commands sent from agents back into the Android UI. */
|
||||
enum class OpenClawCanvasA2UICommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -50,7 +47,6 @@ enum class OpenClawCanvasA2UICommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Camera command ids accepted by the Android node. */
|
||||
enum class OpenClawCameraCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -64,7 +60,6 @@ enum class OpenClawCameraCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** SMS command ids accepted by the Android node. */
|
||||
enum class OpenClawSmsCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -77,7 +72,6 @@ enum class OpenClawSmsCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Push-to-talk command ids accepted by the Android node. */
|
||||
enum class OpenClawTalkCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -92,7 +86,6 @@ enum class OpenClawTalkCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Location command ids accepted by the Android node. */
|
||||
enum class OpenClawLocationCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -104,7 +97,6 @@ enum class OpenClawLocationCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Device status and metadata command ids accepted by the Android node. */
|
||||
enum class OpenClawDeviceCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -112,7 +104,6 @@ enum class OpenClawDeviceCommand(
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
Apps("device.apps"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
@@ -120,7 +111,6 @@ enum class OpenClawDeviceCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Notification command ids accepted by the Android node. */
|
||||
enum class OpenClawNotificationsCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -133,7 +123,6 @@ enum class OpenClawNotificationsCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** System command ids accepted by the Android node. */
|
||||
enum class OpenClawSystemCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -145,7 +134,6 @@ enum class OpenClawSystemCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Photos command ids accepted by the Android node. */
|
||||
enum class OpenClawPhotosCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -157,7 +145,6 @@ enum class OpenClawPhotosCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Contacts command ids accepted by the Android node. */
|
||||
enum class OpenClawContactsCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -170,7 +157,6 @@ enum class OpenClawContactsCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Calendar command ids accepted by the Android node. */
|
||||
enum class OpenClawCalendarCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -183,7 +169,6 @@ enum class OpenClawCalendarCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Motion sensor command ids accepted by the Android node. */
|
||||
enum class OpenClawMotionCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
@@ -196,7 +181,6 @@ enum class OpenClawMotionCommand(
|
||||
}
|
||||
}
|
||||
|
||||
/** Call-log command ids accepted by the Android node. */
|
||||
enum class OpenClawCallLogCommand(
|
||||
val rawValue: String,
|
||||
) {
|
||||
|
||||
@@ -31,7 +31,6 @@ private data class ToolDisplayConfig(
|
||||
val tools: Map<String, ToolDisplaySpec>? = null,
|
||||
)
|
||||
|
||||
/** Compact UI summary for a running or pending tool call. */
|
||||
data class ToolDisplaySummary(
|
||||
val name: String,
|
||||
val emoji: String,
|
||||
@@ -40,7 +39,6 @@ data class ToolDisplaySummary(
|
||||
val verb: String?,
|
||||
val detail: String?,
|
||||
) {
|
||||
/** Optional second-line detail assembled from the action verb and best argument preview. */
|
||||
val detailLine: String?
|
||||
get() {
|
||||
val parts = mutableListOf<String>()
|
||||
@@ -49,12 +47,10 @@ data class ToolDisplaySummary(
|
||||
return if (parts.isEmpty()) null else parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** Single-line fallback for compact tool rows that do not render detail separately. */
|
||||
val summaryLine: String
|
||||
get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label"
|
||||
}
|
||||
|
||||
/** Resolves tool-call names and args into user-facing Android display text. */
|
||||
object ToolDisplayRegistry {
|
||||
private const val CONFIG_ASSET = "tool-display.json"
|
||||
|
||||
@@ -62,7 +58,6 @@ object ToolDisplayRegistry {
|
||||
|
||||
@Volatile private var cachedConfig: ToolDisplayConfig? = null
|
||||
|
||||
/** Resolves a raw tool call into stable, bounded UI text for pending-tool surfaces. */
|
||||
fun resolve(
|
||||
context: Context,
|
||||
name: String?,
|
||||
@@ -91,8 +86,6 @@ object ToolDisplayRegistry {
|
||||
detail = pathDetail(args)
|
||||
}
|
||||
|
||||
// Action-specific detail keys win over tool defaults so commands like
|
||||
// read/write can surface the most useful argument for that action.
|
||||
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
|
||||
if (detail == null) {
|
||||
detail = firstValue(args, detailKeys)
|
||||
@@ -129,8 +122,6 @@ object ToolDisplayRegistry {
|
||||
cachedConfig = decoded
|
||||
decoded
|
||||
} catch (_: Throwable) {
|
||||
// The chat UI should still render pending tools if the asset is absent or
|
||||
// malformed in debug builds.
|
||||
val fallback = ToolDisplayConfig()
|
||||
cachedConfig = fallback
|
||||
fallback
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** Full-screen white flash keyed by camera capture tokens. */
|
||||
@Composable
|
||||
fun CameraFlashOverlay(
|
||||
token: Long,
|
||||
@@ -30,8 +29,6 @@ private fun CameraFlash(token: Long) {
|
||||
var alpha by remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(token) {
|
||||
if (token == 0L) return@LaunchedEffect
|
||||
// Token changes replay the animation even when consecutive captures use
|
||||
// the same HUD message.
|
||||
alpha = 0.85f
|
||||
delay(110)
|
||||
alpha = 0f
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** Hosts the gateway canvas WebView and attaches it to the runtime canvas controller. */
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Suppress("DEPRECATION")
|
||||
@Composable
|
||||
@@ -152,9 +151,6 @@ fun CanvasScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The listener accepts any WebView origin at registration time because
|
||||
// gateway A2UI URLs are dynamic; CanvasActionTrust validates the live URL
|
||||
// before forwarding each message.
|
||||
val bridge =
|
||||
CanvasA2UIActionBridge(
|
||||
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
|
||||
@@ -188,7 +184,6 @@ fun CanvasScreen(
|
||||
)
|
||||
}
|
||||
|
||||
/** Filters WebView postMessage payloads before they enter the A2UI action handler. */
|
||||
internal class CanvasA2UIActionBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val onMessage: (String) -> Unit,
|
||||
|
||||
@@ -31,7 +31,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Settings detail surface for live canvas status, refresh, and embedded preview. */
|
||||
@Composable
|
||||
internal fun CanvasSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -48,8 +47,6 @@ internal fun CanvasSettingsScreen(
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
// Refresh once when the gateway comes online so the settings preview is
|
||||
// populated before the user manually asks for a rehydrate.
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Settings screen for gateway channel readiness and account status. */
|
||||
@Composable
|
||||
internal fun ChannelsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -72,8 +71,6 @@ internal fun ChannelsSettingsScreen(
|
||||
}
|
||||
}
|
||||
if (summary.partial || summary.warnings.isNotEmpty()) {
|
||||
// Partial channel scans still include useful rows; surface the warning
|
||||
// without hiding successful channel status.
|
||||
ClawPanel {
|
||||
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
@@ -159,5 +156,4 @@ private fun channelBadge(label: String): String =
|
||||
.joinToString("")
|
||||
.ifBlank { "C" }
|
||||
|
||||
/** Chooses the first gateway warning or a generic partial-scan message. */
|
||||
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."
|
||||
|
||||
@@ -4,7 +4,6 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.chat.ChatSheetContent
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/** Keeps the public shell entry point stable while chat internals live under ui.chat. */
|
||||
@Composable
|
||||
fun ChatSheet(viewModel: MainViewModel) {
|
||||
ChatSheetContent(viewModel = viewModel)
|
||||
|
||||
@@ -50,7 +50,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Full-screen command palette for navigation and recent-session search. */
|
||||
@Composable
|
||||
internal fun CommandPalette(
|
||||
viewModel: MainViewModel,
|
||||
@@ -159,7 +158,6 @@ private data class CommandItem(
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
) {
|
||||
/** Matches palette queries against both action title and explanatory subtitle. */
|
||||
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
|
||||
}
|
||||
|
||||
@@ -297,7 +295,6 @@ private fun CommandSectionLabel(title: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds provider quick-action metadata from current gateway/catalog state. */
|
||||
private fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
@@ -310,10 +307,8 @@ private fun providerCommandSubtitle(
|
||||
return "Configure model access"
|
||||
}
|
||||
|
||||
/** Falls back to the canonical main-session label when gateway display names are blank. */
|
||||
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
|
||||
/** Formats command-palette session timestamps for compact rows. */
|
||||
private fun commandRelativeTime(updatedAtMs: Long): String {
|
||||
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
|
||||
val minutes = deltaMs / 60_000L
|
||||
|
||||
@@ -61,7 +61,6 @@ private enum class ConnectInputMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
/** Gateway connection screen for setup-code and manual endpoint pairing. */
|
||||
@Composable
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
@@ -292,8 +291,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
|
||||
validationText = null
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
// Setup-code auth should replace old bootstrap/shared credentials;
|
||||
// manual reconnects keep existing typed credentials.
|
||||
viewModel.resetGatewaySetupAuth()
|
||||
}
|
||||
viewModel.setManualEnabled(true)
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Settings screen for gateway dreaming state and recent dream diary entries. */
|
||||
@Composable
|
||||
internal fun DreamingSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -188,7 +187,6 @@ private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats the next dreaming cycle as a compact relative label. */
|
||||
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
|
||||
val next = nextRunAtMs ?: return "Not scheduled"
|
||||
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.net.URI
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
|
||||
/** Parsed endpoint fields after URL validation and cleartext-safety checks. */
|
||||
internal data class GatewayEndpointConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
@@ -18,7 +17,6 @@ internal data class GatewayEndpointConfig(
|
||||
val displayUrl: String,
|
||||
)
|
||||
|
||||
/** Decoded setup-code payload; only one credential family is expected to be populated. */
|
||||
internal data class GatewaySetupCode(
|
||||
val url: String,
|
||||
val bootstrapToken: String?,
|
||||
@@ -26,7 +24,6 @@ internal data class GatewaySetupCode(
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
/** Final gateway connection fields selected from setup-code or manual UI input. */
|
||||
internal data class GatewayConnectConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
@@ -36,26 +33,22 @@ internal data class GatewayConnectConfig(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
/** Validation reason used by setup, QR, and manual endpoint copy. */
|
||||
internal enum class GatewayEndpointValidationError {
|
||||
INVALID_URL,
|
||||
INSECURE_REMOTE_URL,
|
||||
}
|
||||
|
||||
/** User input source used to choose endpoint-validation wording. */
|
||||
internal enum class GatewayEndpointInputSource {
|
||||
SETUP_CODE,
|
||||
MANUAL,
|
||||
QR_SCAN,
|
||||
}
|
||||
|
||||
/** Endpoint parse result that preserves the reason when no usable config exists. */
|
||||
internal data class GatewayEndpointParseResult(
|
||||
val config: GatewayEndpointConfig? = null,
|
||||
val error: GatewayEndpointValidationError? = null,
|
||||
)
|
||||
|
||||
/** QR scan result that separates a usable setup code from validation copy. */
|
||||
internal data class GatewayScannedSetupCodeResult(
|
||||
val setupCode: String? = null,
|
||||
val error: GatewayEndpointValidationError? = null,
|
||||
@@ -67,7 +60,6 @@ private const val remoteGatewaySecurityRule =
|
||||
private const val remoteGatewaySecurityFix =
|
||||
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
|
||||
|
||||
/** Resolves setup-code or manual UI fields into a connection config. */
|
||||
internal fun resolveGatewayConnectConfig(
|
||||
useSetupCode: Boolean,
|
||||
setupCode: String,
|
||||
@@ -85,8 +77,6 @@ internal fun resolveGatewayConnectConfig(
|
||||
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
||||
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
|
||||
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
|
||||
// Bootstrap setup codes intentionally suppress stale shared credentials;
|
||||
// the bootstrap token owns the first authenticated pairing exchange.
|
||||
val sharedToken =
|
||||
when {
|
||||
!setup.token.isNullOrBlank() -> setup.token.trim()
|
||||
@@ -131,10 +121,8 @@ internal fun resolveGatewayConnectConfig(
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses an endpoint string and returns only the valid connection config. */
|
||||
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
|
||||
|
||||
/** Parses and validates gateway endpoint input with user-facing error reasons. */
|
||||
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
|
||||
val raw = rawInput.trim()
|
||||
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
@@ -178,7 +166,6 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
|
||||
)
|
||||
}
|
||||
|
||||
/** Decodes base64url setup-code payloads produced by gateway onboarding. */
|
||||
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
val trimmed = rawInput.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
@@ -206,10 +193,8 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
|
||||
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
|
||||
|
||||
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
|
||||
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
|
||||
val setupCode =
|
||||
resolveSetupCodeCandidate(rawInput)
|
||||
@@ -224,7 +209,6 @@ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetu
|
||||
return GatewayScannedSetupCodeResult(setupCode = setupCode)
|
||||
}
|
||||
|
||||
/** Converts endpoint validation errors into setup-source-specific UI copy. */
|
||||
internal fun gatewayEndpointValidationMessage(
|
||||
error: GatewayEndpointValidationError,
|
||||
source: GatewayEndpointInputSource,
|
||||
@@ -247,7 +231,6 @@ internal fun gatewayEndpointValidationMessage(
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds a URL from manual host/port/tls fields for shared endpoint parsing. */
|
||||
internal fun composeGatewayManualUrl(
|
||||
hostInput: String,
|
||||
portInput: String,
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
|
||||
/** App version label shared by diagnostics and gateway-facing Android metadata. */
|
||||
internal fun openClawAndroidVersionLabel(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
@@ -17,22 +16,18 @@ internal fun openClawAndroidVersionLabel(): String {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizes blank gateway status text for display and diagnostics copy. */
|
||||
internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { "Offline" }
|
||||
|
||||
/** Returns true when the status has enough signal to show diagnostics affordances. */
|
||||
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower != "offline" && !lower.contains("connecting")
|
||||
}
|
||||
|
||||
/** Detects pairing/approval status text so UI can offer pairing-specific actions. */
|
||||
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
|
||||
val lower = gatewayStatusForDisplay(statusText).lowercase()
|
||||
return lower.contains("pair") || lower.contains("approve")
|
||||
}
|
||||
|
||||
/** Builds the copyable support prompt with device, endpoint, and exact status context. */
|
||||
internal fun buildGatewayDiagnosticsReport(
|
||||
screen: String,
|
||||
gatewayAddress: String,
|
||||
@@ -72,7 +67,6 @@ internal fun buildGatewayDiagnosticsReport(
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/** Copies the diagnostics report to Android clipboard and shows a short confirmation toast. */
|
||||
internal fun copyGatewayDiagnosticsReport(
|
||||
context: Context,
|
||||
screen: String,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user