mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 14:31:35 +08:00
Compare commits
238 Commits
refactor/s
...
josh/trans
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ea501f38 | ||
|
|
4b1e5b7943 | ||
|
|
92b6af76d9 | ||
|
|
53a9f13cf4 | ||
|
|
b2f71db7bb | ||
|
|
6fb1f386c6 | ||
|
|
ae4ab2a41f | ||
|
|
4f3d8a57dd | ||
|
|
f454d6202f | ||
|
|
1556e3c68c | ||
|
|
a4d3add6da | ||
|
|
b4cdc33fc9 | ||
|
|
c2c20a0b0d | ||
|
|
a753e6bc86 | ||
|
|
425a4ab2f2 | ||
|
|
724160b7eb | ||
|
|
6699e7331a | ||
|
|
b0625bdd1c | ||
|
|
4ca22b95bc | ||
|
|
3950605561 | ||
|
|
2c6a3f6b04 | ||
|
|
7bf6667d6c | ||
|
|
778c4f90b9 | ||
|
|
75ea8b5094 | ||
|
|
9c1adf4e51 | ||
|
|
f94512cd7f | ||
|
|
d9d5d97dbc | ||
|
|
056bc46a67 | ||
|
|
ed46e62bcc | ||
|
|
1d55caa162 | ||
|
|
17c2e95334 | ||
|
|
a0d2febe6b | ||
|
|
70c8abdca1 | ||
|
|
9e2bd8b2f7 | ||
|
|
2d3fa4832f | ||
|
|
7606e1dd3d | ||
|
|
ef7854abbc | ||
|
|
53e063962d | ||
|
|
cef86c1748 | ||
|
|
ee39aa84b2 | ||
|
|
e93e2a0f18 | ||
|
|
fce45a2178 | ||
|
|
2a58d92655 | ||
|
|
b335018c3c | ||
|
|
80294a4f6b | ||
|
|
20ab73e7d4 | ||
|
|
a041e393c1 | ||
|
|
2e0d191725 | ||
|
|
ec949a856e | ||
|
|
0b9193c0b7 | ||
|
|
aa56f592bb | ||
|
|
10b4057c36 | ||
|
|
ecef6ae626 | ||
|
|
f456114b12 | ||
|
|
617c658498 | ||
|
|
3258338ec8 | ||
|
|
3a4943ef87 | ||
|
|
84b025eb62 | ||
|
|
a776de25e8 | ||
|
|
b4aaca3365 | ||
|
|
f5eca3f84c | ||
|
|
ea11b8ad3d | ||
|
|
d4f78c9339 | ||
|
|
15ae2deb30 | ||
|
|
e6ce83487c | ||
|
|
3513e8bfd9 | ||
|
|
0f1767a26a | ||
|
|
63a95930ca | ||
|
|
b81adc6202 | ||
|
|
0ac725278d | ||
|
|
4471335d26 | ||
|
|
b78dd6a9ca | ||
|
|
15c1511817 | ||
|
|
5e1e029d91 | ||
|
|
48ccc50282 | ||
|
|
5a8bb1a7d2 | ||
|
|
e1ad5f5170 | ||
|
|
88203c9b10 | ||
|
|
b48c72cd19 | ||
|
|
8a40f90f62 | ||
|
|
ae651e7210 | ||
|
|
4d95ae39d4 | ||
|
|
e6782254e4 | ||
|
|
59694e86d9 | ||
|
|
87664ed096 | ||
|
|
1fd9fe2b33 | ||
|
|
122ae5db9e | ||
|
|
930b371a2f | ||
|
|
b9fe0894a6 | ||
|
|
444562b3de | ||
|
|
ce547bfd44 | ||
|
|
d4d7fdbc59 | ||
|
|
096bd13962 | ||
|
|
4eba3e5d7d | ||
|
|
9c3cf35e08 | ||
|
|
deb7bc6539 | ||
|
|
0211a3aa9f | ||
|
|
ade6e7769b | ||
|
|
f1cb9f2f6a | ||
|
|
667393be8f | ||
|
|
72c61bc123 | ||
|
|
b372af6b81 | ||
|
|
04b68e8fa4 | ||
|
|
9f99acf12d | ||
|
|
23dac6c263 | ||
|
|
8a4679026c | ||
|
|
0303f3a8f0 | ||
|
|
bb680a845b | ||
|
|
5de0d873ed | ||
|
|
83597b7f95 | ||
|
|
edd8aa2f4e | ||
|
|
fab8d29d21 | ||
|
|
7595d52e56 | ||
|
|
11c050d0d0 | ||
|
|
764321d3d3 | ||
|
|
54a27f4e57 | ||
|
|
84061c1f8e | ||
|
|
5c38c0c76d | ||
|
|
db94eac5c0 | ||
|
|
92f1d90e0f | ||
|
|
a1d7a7536a | ||
|
|
287f531de6 | ||
|
|
d06e1b2c71 | ||
|
|
5b0036ffde | ||
|
|
dca53afd53 | ||
|
|
604339ebf9 | ||
|
|
74c5548c0d | ||
|
|
33c44626d2 | ||
|
|
68b5371fca | ||
|
|
9417b9cea8 | ||
|
|
0c97922e23 | ||
|
|
2bdb2e8e02 | ||
|
|
984951f55c | ||
|
|
59f96078b2 | ||
|
|
f0c0181b10 | ||
|
|
84dec338e7 | ||
|
|
66a4410095 | ||
|
|
b26f89213e | ||
|
|
ae4ddece91 | ||
|
|
9161db534e | ||
|
|
cae98c1daf | ||
|
|
3d0dc15904 | ||
|
|
f71df664c9 | ||
|
|
4ca218246e | ||
|
|
11b5968534 | ||
|
|
5e139e32dc | ||
|
|
620acafb15 | ||
|
|
8247f824b9 | ||
|
|
c767b37e3b | ||
|
|
21478cab93 | ||
|
|
dbddf4093f | ||
|
|
1cf264c468 | ||
|
|
9d866d8b2a | ||
|
|
0e3cc2e5ad | ||
|
|
1670b970ee | ||
|
|
37fbc8cd8f | ||
|
|
7eeea30d8c | ||
|
|
cdab5fc16a | ||
|
|
aa42905354 | ||
|
|
fbee4d56c4 | ||
|
|
ed59533574 | ||
|
|
1f92a3e351 | ||
|
|
ac4ebc053a | ||
|
|
9b2146775f | ||
|
|
bc38a929aa | ||
|
|
8e12c6ea1f | ||
|
|
65167c9637 | ||
|
|
87eae7f811 | ||
|
|
b4e331fe81 | ||
|
|
c8d458d13d | ||
|
|
2cbfb910f2 | ||
|
|
1c1f42a74a | ||
|
|
3734ed6402 | ||
|
|
5e11c85c0a | ||
|
|
4135771adf | ||
|
|
58db38e088 | ||
|
|
e500f401ea | ||
|
|
1ff52b2786 | ||
|
|
2b3f09659c | ||
|
|
0d391bacf7 | ||
|
|
85c8e7f89f | ||
|
|
1987d364b5 | ||
|
|
971cb2d4bd | ||
|
|
c66b21662d | ||
|
|
6e318684c1 | ||
|
|
89022023a3 | ||
|
|
10e8426aa5 | ||
|
|
79d3083eb6 | ||
|
|
95e2427189 | ||
|
|
29a67f4d11 | ||
|
|
2d2ab6d480 | ||
|
|
cb79864cb9 | ||
|
|
0530596eef | ||
|
|
417022864b | ||
|
|
2833d4b347 | ||
|
|
490a155f15 | ||
|
|
5f7217db2c | ||
|
|
de1c4f8aec | ||
|
|
3a31b34151 | ||
|
|
8ef5b5ddba | ||
|
|
0adf3220b8 | ||
|
|
edb2c498d5 | ||
|
|
00831cf8ff | ||
|
|
b0ca5d7407 | ||
|
|
c9311ef0a9 | ||
|
|
1a4d2f7cca | ||
|
|
9d31cbbd6a | ||
|
|
bbc4bee7a2 | ||
|
|
ef9e9bf6b9 | ||
|
|
51dee73a5d | ||
|
|
b858d418aa | ||
|
|
e1a9817141 | ||
|
|
4dad7bd93b | ||
|
|
26913e60a4 | ||
|
|
5c5711f061 | ||
|
|
1e3542bbe7 | ||
|
|
9a00d74044 | ||
|
|
e9d01320d7 | ||
|
|
ae800e160d | ||
|
|
e086bfeb91 | ||
|
|
dd72b104ac | ||
|
|
199a1b9014 | ||
|
|
00d8d7ead0 | ||
|
|
94814f3516 | ||
|
|
9caefeaf08 | ||
|
|
16bae70af4 | ||
|
|
6270d5326f | ||
|
|
6561bdc41d | ||
|
|
54d42c7c9a | ||
|
|
ab35dcd333 | ||
|
|
4c33aaa86c | ||
|
|
8d6a6e9d03 | ||
|
|
7fc02d36b3 | ||
|
|
7920af0c9e | ||
|
|
3e7f74505c | ||
|
|
38d3d11cbc | ||
|
|
8be581cbf8 | ||
|
|
d05e4a4bc6 |
@@ -223,6 +223,21 @@ Read the JSON summary and the Testbox line. Useful fields:
|
||||
- Actions run URL/id from the Testbox output
|
||||
- `exitCode`
|
||||
|
||||
Use provider-backed cache volumes only for rebuildable caches, not secrets or
|
||||
checkout state. On Blacksmith, Crabbox forwards them as sticky disks:
|
||||
|
||||
```sh
|
||||
node scripts/crabbox-wrapper.mjs run \
|
||||
--provider blacksmith-testbox \
|
||||
--cache-volume pnpm-store=openclaw-node24-pnpm-lock:/tmp/openclaw-pnpm-store \
|
||||
--timing-json \
|
||||
-- \
|
||||
corepack pnpm check:changed
|
||||
```
|
||||
|
||||
The selected provider must advertise cache-volume support. If not, omit
|
||||
`--cache-volume` and rely on kept-lease caches.
|
||||
|
||||
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
|
||||
|
||||
```sh
|
||||
@@ -292,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 \
|
||||
@@ -304,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
|
||||
|
||||
@@ -590,7 +604,8 @@ Crabbox Blacksmith backend delegates setup to:
|
||||
|
||||
The hydration workflow owns checkout, Node/pnpm setup, dependency install,
|
||||
secrets, ready marker, and keepalive. Crabbox owns dispatch, sync, SSH command
|
||||
execution, timing, logs/results, and cleanup.
|
||||
execution, timing, logs/results, cleanup, and cache-volume requests. Blacksmith
|
||||
implements cache volumes as sticky disks.
|
||||
|
||||
Minimal Blacksmith-backed Crabbox run, from repo root:
|
||||
|
||||
@@ -685,6 +700,7 @@ crabbox events <run_id> --json
|
||||
crabbox logs <run_id>
|
||||
crabbox results <run_id>
|
||||
crabbox cache stats --id <id-or-slug>
|
||||
crabbox cache volumes
|
||||
crabbox ssh --id <id-or-slug>
|
||||
blacksmith testbox list
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -7,8 +7,16 @@ queries:
|
||||
- uses: ./.github/codeql/openclaw-boundary/queries/managed-proxy-runtime-mutation.ql
|
||||
|
||||
paths:
|
||||
- src
|
||||
- extensions
|
||||
- src/cli/gateway-cli/run-loop.ts
|
||||
- src/infra/gateway-lock.ts
|
||||
- src/infra/jsonl-socket.ts
|
||||
- src/infra/net
|
||||
- src/infra/push-apns-http2.ts
|
||||
- src/infra/ssh-tunnel.ts
|
||||
- src/proxy-capture
|
||||
- extensions/codex-supervisor/src/json-rpc-client.ts
|
||||
- extensions/irc/src
|
||||
- extensions/qa-lab/src
|
||||
- packages/net-policy/src
|
||||
|
||||
paths-ignore:
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -132,6 +132,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/slack/**"
|
||||
- "docs/channels/slack.md"
|
||||
"channel: sms":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/sms/**"
|
||||
- "docs/channels/sms.md"
|
||||
"channel: synology-chat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
3
.github/workflows/ci-check-testbox.yml
vendored
3
.github/workflows/ci-check-testbox.yml
vendored
@@ -15,6 +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_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
|
||||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -1079,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
|
||||
@@ -1972,6 +1971,21 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: OpenClawKit Talk-trait opt-out (no ElevenLabsKit when default traits disabled)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Guard: chat-only consumers build OpenClawKit with the Talk trait
|
||||
# disabled and must NOT link ElevenLabsKit. Assert that future sources
|
||||
# under OpenClawKit cannot silently reintroduce an unconditional
|
||||
# ElevenLabsKit dependency while the manifest still looks correct.
|
||||
deps="$(swift package --package-path apps/shared/OpenClawKit show-dependencies --disable-default-traits)"
|
||||
echo "$deps"
|
||||
if grep -qi 'elevenlabs' <<<"$deps"; then
|
||||
echo "::error::ElevenLabsKit resolved with the Talk trait disabled; keep it gated behind the Talk trait."
|
||||
exit 1
|
||||
fi
|
||||
swift build --package-path apps/shared/OpenClawKit --target OpenClawKit --disable-default-traits
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
34
.github/workflows/codeql-critical-quality.yml
vendored
34
.github/workflows/codeql-critical-quality.yml
vendored
@@ -210,6 +210,9 @@ jobs:
|
||||
else
|
||||
while IFS= read -r file; do
|
||||
case "${file}" in
|
||||
.github/codeql/codeql-network-runtime-boundary-critical-quality.yml|.github/codeql/openclaw-boundary/queries/raw-socket-callsite-classification.ql|.github/codeql/openclaw-boundary/queries/managed-proxy-runtime-mutation.ql)
|
||||
network_runtime=true
|
||||
;;
|
||||
.github/codeql/*|.github/workflows/codeql-critical-quality.yml)
|
||||
agent=true
|
||||
channel=true
|
||||
@@ -222,7 +225,6 @@ jobs:
|
||||
plugin_sdk_package=true
|
||||
plugin_sdk_reply=true
|
||||
provider=true
|
||||
network_runtime=true
|
||||
session_diagnostics=true
|
||||
;;
|
||||
src/agents/sessions/tools/*)
|
||||
@@ -304,7 +306,7 @@ jobs:
|
||||
case "${file}" in
|
||||
src/**/*.test.ts|src/**/*.test.tsx|extensions/**/*.test.ts|extensions/**/*.test.tsx)
|
||||
;;
|
||||
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
|
||||
packages/net-policy/src/*|packages/net-policy/src/**/*|src/cli/gateway-cli/run-loop.ts|src/infra/net/*|src/infra/net/**/*|src/infra/ssh-tunnel.ts|src/infra/gateway-lock.ts|src/infra/jsonl-socket.ts|src/infra/push-apns-http2.ts|src/proxy-capture/*|src/proxy-capture/**/*|extensions/codex-supervisor/src/json-rpc-client.ts|extensions/irc/src/*|extensions/qa-lab/src/*)
|
||||
network_runtime=true
|
||||
;;
|
||||
esac
|
||||
@@ -431,7 +433,33 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Fast PR network boundary diff scan
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
added_lines="$(mktemp)"
|
||||
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '
|
||||
.[]
|
||||
| select(.filename | test("^(src/cli/gateway-cli/run-loop\\.ts|src/infra/(gateway-lock|jsonl-socket|push-apns-http2|ssh-tunnel)\\.ts|src/infra/net/|src/proxy-capture/|extensions/codex-supervisor/src/json-rpc-client\\.ts|extensions/irc/src/|extensions/qa-lab/src/|packages/net-policy/src/)"))
|
||||
| .filename as $file
|
||||
| (.patch // "")
|
||||
| split("\n")[]
|
||||
| select(startswith("+") and (startswith("+++") | not))
|
||||
| "\($file): \(.)"
|
||||
' > "$added_lines"
|
||||
|
||||
if grep -En '(from|require\().*["'\''](node:)?(net|tls|http2)["'\'']|\b(net|tls|http2)\.(connect|createConnection)\b|new Socket\(|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|GLOBAL_AGENT_|OPENCLAW_PROXY_' "$added_lines"; then
|
||||
echo "Network runtime boundary-sensitive added lines require full CodeQL review." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
@@ -439,12 +467,14 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
|
||||
- name: Fail on network runtime boundary findings
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
SARIF_OUTPUT: sarif-results
|
||||
run: |
|
||||
|
||||
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 != ''
|
||||
|
||||
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:
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -59,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/**
|
||||
@@ -101,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/
|
||||
|
||||
@@ -128,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",
|
||||
@@ -20,34 +39,44 @@
|
||||
"eslint/no-multi-str": "error",
|
||||
"eslint/no-new": "error",
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-param-reassign": "error",
|
||||
"eslint/no-proto": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-return-assign": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
"eslint/no-self-compare": "error",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-implicit-coercion": "error",
|
||||
"eslint/no-var": "error",
|
||||
"eslint/no-useless-call": "error",
|
||||
"eslint/no-useless-computed-key": "error",
|
||||
"eslint/no-useless-concat": "error",
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-useless-rename": "error",
|
||||
"eslint/no-useless-return": "error",
|
||||
"eslint/no-unused-vars": "off",
|
||||
"eslint/no-warning-comments": "error",
|
||||
"eslint/no-unmodified-loop-condition": "error",
|
||||
"eslint/no-new-wrappers": "error",
|
||||
"eslint/no-else-return": "error",
|
||||
"eslint/no-lonely-if": "error",
|
||||
"eslint/no-case-declarations": "error",
|
||||
"eslint/default-case-last": "error",
|
||||
"eslint/default-param-last": "error",
|
||||
"eslint/prefer-exponentiation-operator": "error",
|
||||
"eslint/prefer-const": "error",
|
||||
"eslint/prefer-numeric-literals": "error",
|
||||
"eslint/prefer-object-has-own": "error",
|
||||
"eslint/object-shorthand": "error",
|
||||
"eslint/prefer-rest-params": "error",
|
||||
"eslint/prefer-spread": "error",
|
||||
"eslint/radix": "error",
|
||||
"eslint/unicode-bom": "error",
|
||||
"eslint/yoda": "error",
|
||||
"import/no-absolute-path": "error",
|
||||
"import/first": "error",
|
||||
"import/no-empty-named-blocks": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"import/no-self-import": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"eslint-plugin-unicorn/prefer-set-size": "error",
|
||||
@@ -66,7 +95,9 @@
|
||||
"typescript/no-empty-object-type": ["error", { "allowInterfaces": "with-single-extends" }],
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extraneous-class": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/no-non-null-asserted-nullish-coalescing": "error",
|
||||
"typescript/no-unnecessary-qualifier": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
@@ -86,6 +117,7 @@
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/prefer-return-this-type": "error",
|
||||
"typescript/prefer-find": "error",
|
||||
"typescript/prefer-for-of": "error",
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-reduce-type-parameter": "error",
|
||||
@@ -106,14 +138,17 @@
|
||||
"unicorn/no-new-buffer": "error",
|
||||
"unicorn/no-thenable": "error",
|
||||
"unicorn/no-typeof-undefined": "error",
|
||||
"unicorn/no-unreadable-array-destructuring": "error",
|
||||
"unicorn/no-unnecessary-array-flat-depth": "error",
|
||||
"unicorn/no-unnecessary-array-splice-count": "error",
|
||||
"unicorn/no-unnecessary-slice-end": "error",
|
||||
"unicorn/no-useless-error-capture-stack-trace": "error",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-zero-fractions": "error",
|
||||
"unicorn/prefer-date-now": "error",
|
||||
"unicorn/prefer-dom-node-text-content": "error",
|
||||
"unicorn/prefer-keyboard-event-key": "error",
|
||||
"unicorn/prefer-array-flat": "error",
|
||||
"unicorn/prefer-array-some": "error",
|
||||
"unicorn/prefer-math-min-max": "error",
|
||||
"unicorn/prefer-node-protocol": "error",
|
||||
@@ -123,6 +158,8 @@
|
||||
"unicorn/prefer-prototype-methods": "error",
|
||||
"unicorn/prefer-regexp-test": "error",
|
||||
"unicorn/prefer-set-size": "error",
|
||||
"unicorn/prefer-set-has": "error",
|
||||
"unicorn/prefer-structured-clone": "error",
|
||||
"unicorn/prefer-string-starts-ends-with": "error",
|
||||
"unicorn/prefer-string-slice": "error",
|
||||
"unicorn/require-array-join-separator": "error",
|
||||
@@ -183,6 +220,7 @@
|
||||
"docs/_layouts/",
|
||||
"extensions/diffs/assets/viewer-runtime.js",
|
||||
"extensions/diffs-language-pack/assets/viewer-runtime.js",
|
||||
"extensions/canvas/src/host/a2ui/a2ui.bundle.js",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml",
|
||||
@@ -199,13 +237,6 @@
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/security/**"],
|
||||
"rules": {
|
||||
"eslint/no-warning-comments": "off",
|
||||
"oxc/no-map-spread": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.test.ts",
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -154,19 +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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -211,6 +201,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Never commit real phone numbers, videos, credentials, live config.
|
||||
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
|
||||
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
|
||||
- Release/package guards: no hard-coded retired-package denylists; use generic artifact/dependency checks or fix build source.
|
||||
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
|
||||
@@ -230,6 +221,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- 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.
|
||||
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
|
||||
- Rebrand/migration/config warnings: run `openclaw doctor`.
|
||||
- Never edit `node_modules`.
|
||||
|
||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -15,10 +15,11 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Skills: let the `skill_research` agent tool apply, reject, and quarantine explicit Skill Workshop proposals through the guarded proposal lifecycle. Thanks @shakkernerd.
|
||||
- Skills: let Skill Workshop proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
|
||||
- Skills: let pending Skill Workshop proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
|
||||
- Skills: add Skill Workshop proposals with pending `PROPOSAL.md` drafts, CLI/Gateway review actions, rollback metadata, and the `skill_research` agent tool. Thanks @shakkernerd.
|
||||
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
|
||||
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
|
||||
- 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.
|
||||
- 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)
|
||||
@@ -30,6 +31,9 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- 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.
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- 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)
|
||||
@@ -56,6 +60,8 @@ Docs: https://docs.openclaw.ai
|
||||
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
|
||||
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
|
||||
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
|
||||
- Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.
|
||||
- Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.
|
||||
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
|
||||
- Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.
|
||||
- Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.
|
||||
@@ -404,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.
|
||||
@@ -427,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.
|
||||
@@ -438,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.
|
||||
@@ -1360,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.
|
||||
|
||||
@@ -1539,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.
|
||||
@@ -1737,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.
|
||||
|
||||
@@ -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
|
||||
@@ -79,6 +80,7 @@ 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),
|
||||
) {
|
||||
data class GatewayConnectAuth(
|
||||
val token: String?,
|
||||
@@ -88,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)
|
||||
@@ -109,7 +110,6 @@ class NodeRuntime(
|
||||
|
||||
private val cameraHandler: CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
@@ -119,7 +119,6 @@ class NodeRuntime(
|
||||
|
||||
private val debugHandler: DebugHandler =
|
||||
DebugHandler(
|
||||
appContext = appContext,
|
||||
identityStore = identityStore,
|
||||
)
|
||||
|
||||
@@ -2871,7 +2870,7 @@ fun providerDisplayName(provider: String): String =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> "OpenAI"
|
||||
"openrouter" -> "OpenRouter"
|
||||
"openai-codex", "codex" -> "Codex"
|
||||
"codex" -> "Codex"
|
||||
"ollama", "ollama-local" -> "Ollama Local"
|
||||
else ->
|
||||
provider
|
||||
|
||||
@@ -442,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,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -18,6 +20,10 @@ private data class PersistedDeviceAuthMetadata(
|
||||
val updatedAtMs: Long = 0L,
|
||||
)
|
||||
|
||||
private const val deviceAuthTokenPrefix = "gateway.deviceToken."
|
||||
private const val deviceAuthMetadataPrefix = "gateway.deviceTokenMeta."
|
||||
private const val sqliteSecurePrefsTokenMarker = "__openclaw_secure_prefs__"
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadEntry(
|
||||
deviceId: String,
|
||||
@@ -42,29 +48,104 @@ interface DeviceAuthTokenStore {
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -92,28 +192,124 @@ 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)
|
||||
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)
|
||||
|
||||
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ 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
|
||||
|
||||
@@ -28,16 +29,14 @@ class DeviceIdentityStore(
|
||||
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
|
||||
@@ -111,34 +110,76 @@ 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 {
|
||||
@@ -168,14 +209,6 @@ class DeviceIdentityStore(
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -194,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -19,7 +18,6 @@ internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
|
||||
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
|
||||
|
||||
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,
|
||||
@@ -54,16 +52,12 @@ class CameraHandler(
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -93,18 +87,14 @@ class CameraHandler(
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -3,13 +3,16 @@ 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
|
||||
|
||||
class DebugHandler(
|
||||
private val appContext: Context,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
) {
|
||||
fun handleEd25519(): GatewaySession.InvokeResult {
|
||||
@@ -81,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"
|
||||
// Run logcat on current dispatcher thread (no withContext) with file redirect
|
||||
// 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",
|
||||
@@ -119,7 +112,7 @@ 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) {
|
||||
@@ -129,18 +122,55 @@ class DebugHandler(
|
||||
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})"
|
||||
}
|
||||
// Also include camera debug log if it exists
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -278,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
|
||||
@@ -292,31 +293,45 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
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()) {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
@@ -366,18 +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)
|
||||
val updated = listOf(normalized) + existing
|
||||
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
|
||||
OpenClawSQLiteStateStore(service.applicationContext)
|
||||
.replaceRecentNotificationPackages(updated, recentPackagesLimit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MobileColors – semantic color tokens with light + dark variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal data class MobileColors(
|
||||
val surface: Color,
|
||||
val surfaceStrong: Color,
|
||||
@@ -108,9 +104,6 @@ internal object MobileColorsAccessor {
|
||||
@Composable get() = LocalMobileColors.current
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible top-level accessors (composable getters)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
|
||||
// without converting every file at once. Each resolves to the themed value.
|
||||
|
||||
@@ -149,10 +142,6 @@ internal val mobileBackgroundGradient: Brush
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography tokens (theme-independent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal val mobileFontFamily =
|
||||
FontFamily(
|
||||
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
|
||||
|
||||
@@ -270,7 +270,7 @@ private fun providerPriority(provider: String): Int =
|
||||
"google" -> 2
|
||||
"openrouter" -> 3
|
||||
"ollama", "ollama-local" -> 4
|
||||
"codex", "openai-codex" -> 5
|
||||
"codex" -> 5
|
||||
else -> 100
|
||||
}
|
||||
|
||||
|
||||
@@ -130,9 +130,10 @@ class GatewayBootstrapAuthTest {
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
val authStore = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
val runtime = NodeRuntime(app, prefs, deviceAuthStore = authStore)
|
||||
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
|
||||
DeviceAuthStore(prefs).saveToken(deviceId, "operator", "bootstrap-operator-token")
|
||||
authStore.saveToken(deviceId, "operator", "bootstrap-operator-token")
|
||||
|
||||
writeField(runtime, "operatorStatusText", "Connecting…")
|
||||
invokeMaybeStartOperatorSessionAfterNodeConnect(
|
||||
@@ -192,6 +193,7 @@ class GatewayBootstrapAuthTest {
|
||||
app,
|
||||
prefs,
|
||||
tlsFingerprintProbe = { _, _ -> GatewayTlsProbeResult(fingerprintSha256 = "fp:1") },
|
||||
deviceAuthStore = DeviceAuthStore(app, legacyPrefsOverride = prefs),
|
||||
)
|
||||
val endpoint = GatewayEndpoint.manual(host = "gateway.example", port = 18789)
|
||||
val explicitAuth =
|
||||
@@ -326,9 +328,9 @@ class GatewayBootstrapAuthTest {
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
val authStore = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
val runtime = NodeRuntime(app, prefs, deviceAuthStore = authStore)
|
||||
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
|
||||
val authStore = DeviceAuthStore(prefs)
|
||||
prefs.setGatewayToken("stale-shared-token")
|
||||
prefs.setGatewayBootstrapToken("stale-bootstrap-token")
|
||||
prefs.setGatewayPassword("stale-password")
|
||||
|
||||
@@ -4,27 +4,28 @@ import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DeviceAuthStoreTest {
|
||||
@Before
|
||||
fun resetState() {
|
||||
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenPersistsNormalizedScopesMetadata() {
|
||||
fun saveTokenPersistsNormalizedScopesMetadataInSQLite() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val store = DeviceAuthStore(prefs)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = legacyPrefs(app))
|
||||
|
||||
store.saveToken(
|
||||
deviceId = " Device-1 ",
|
||||
@@ -39,25 +40,255 @@ class DeviceAuthStoreTest {
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
|
||||
assertTrue((entry?.updatedAtMs ?: 0L) > 0L)
|
||||
val row = OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")
|
||||
assertNotNull(row)
|
||||
assertEquals("__openclaw_secure_prefs__", row?.token)
|
||||
assertEquals("""["operator.read","operator.write"]""", row?.scopesJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryReadsLegacyTokenWithoutMetadata() {
|
||||
fun clearTokenUpdatesSQLiteStore() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", "legacy-token")
|
||||
val store = DeviceAuthStore(prefs)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = legacyPrefs(app))
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.read"))
|
||||
|
||||
store.clearToken("device-1", "operator")
|
||||
|
||||
assertNull(store.loadEntry("device-1", "operator"))
|
||||
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryMigratesLegacySecurePrefsToken() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.operator",
|
||||
"""{"scopes":["operator.write"," operator.read ","operator.write"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
|
||||
val entry = DeviceAuthStore(app, legacyPrefsOverride = prefs).loadEntry(" Device-1 ", " Operator ")
|
||||
|
||||
val entry = store.loadEntry("device-1", "operator")
|
||||
assertNotNull(entry)
|
||||
assertEquals("legacy-token", entry?.token)
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(emptyList<String>(), entry?.scopes)
|
||||
assertEquals(0L, entry?.updatedAtMs)
|
||||
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
|
||||
assertEquals(1700000000000L, entry?.updatedAtMs)
|
||||
assertEquals("operator-token", prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryMigratesAllLegacyRolesBeforeSQLiteRowsExist() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.operator",
|
||||
"""{"scopes":["operator.write"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.node",
|
||||
"""{"scopes":["node.connect"],"updatedAtMs":1700000000001}""",
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
val operator = store.loadEntry("device-1", "operator")
|
||||
val node = store.loadEntry("device-1", "node")
|
||||
|
||||
assertEquals("operator-token", operator?.token)
|
||||
assertEquals(listOf("operator.write"), operator?.scopes)
|
||||
assertEquals("node-token", node?.token)
|
||||
assertEquals(listOf("node.connect"), node?.scopes)
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
|
||||
)
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryDoesNotResurrectLegacyRoleAfterSQLiteRowsExist() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.write"))
|
||||
prefs.putString("gateway.deviceToken.device-1.admin", " stale-admin-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.admin",
|
||||
"""{"scopes":["admin"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token-2", scopes = listOf("operator.write"))
|
||||
|
||||
assertNull(store.loadEntry("device-1", "admin"))
|
||||
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "admin"))
|
||||
assertNull(prefs.getString("gateway.deviceToken.device-1.admin"))
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.admin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenMigratesSameDeviceLegacyRolesBeforeFirstSqliteWrite() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " old-operator-token ")
|
||||
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.node",
|
||||
"""{"scopes":["node.connect"],"updatedAtMs":1700000000001}""",
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.write"))
|
||||
|
||||
assertEquals("operator-token", store.loadEntry("device-1", "operator")?.token)
|
||||
assertEquals("node-token", store.loadEntry("device-1", "node")?.token)
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "node")?.token,
|
||||
)
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.node"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryReturnsLegacySecurePrefsTokenWhenSQLiteMigrationFails() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
val metadata = """{"scopes":["operator.read"],"updatedAtMs":1700000000000}"""
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString("gateway.deviceTokenMeta.device-1.operator", metadata)
|
||||
|
||||
val store =
|
||||
DeviceAuthStore.createForTesting(
|
||||
context = app,
|
||||
legacyPrefsOverride = prefs,
|
||||
stateStoreOverride = ThrowingDeviceAuthStateStore(),
|
||||
)
|
||||
val entry = store.loadEntry("device-1", "operator")
|
||||
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals(listOf("operator.read"), entry?.scopes)
|
||||
assertEquals(1700000000000L, entry?.updatedAtMs)
|
||||
assertEquals(" operator-token ", prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertEquals(metadata, prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryMovesPlaintextSqliteTokenBackToSecurePrefs() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
OpenClawSQLiteStateStore(app).upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = "device-1",
|
||||
role = "operator",
|
||||
token = "operator-token",
|
||||
scopesJson = """["operator.read"]""",
|
||||
updatedAtMs = 1700000000000,
|
||||
),
|
||||
)
|
||||
|
||||
val entry = DeviceAuthStore(app, legacyPrefsOverride = prefs).loadEntry("device-1", "operator")
|
||||
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals("operator-token", prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenForDifferentDevicePurgesStaleLegacySecurePrefsTokens() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " stale-token ")
|
||||
prefs.putString(
|
||||
"gateway.deviceTokenMeta.device-1.operator",
|
||||
"""{"scopes":["operator.read"],"updatedAtMs":1700000000000}""",
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-2", "operator", "fresh-token", scopes = listOf("operator.write"))
|
||||
|
||||
assertNull(store.loadEntry("device-1", "operator"))
|
||||
assertNull(prefs.getString("gateway.deviceToken.device-1.operator"))
|
||||
assertNull(prefs.getString("gateway.deviceTokenMeta.device-1.operator"))
|
||||
assertEquals("fresh-token", store.loadEntry("device-2", "operator")?.token)
|
||||
assertEquals("fresh-token", prefs.getString("gateway.deviceToken.device-2.operator"))
|
||||
assertEquals(
|
||||
"__openclaw_secure_prefs__",
|
||||
OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-2", "operator")?.token,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenPrunesForeignLegacyTokensWithoutOrphaningCurrentSqliteRows() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val prefs = legacyPrefs(app)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", " operator-token ")
|
||||
prefs.putString("gateway.deviceToken.device-1.node", " node-token ")
|
||||
prefs.putString("gateway.deviceToken.device-2.operator", " stale-token ")
|
||||
val sqlite = OpenClawSQLiteStateStore(app)
|
||||
sqlite.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = "device-1",
|
||||
role = "operator",
|
||||
token = "__openclaw_secure_prefs__",
|
||||
scopesJson = """["operator.read"]""",
|
||||
updatedAtMs = 1700000000000,
|
||||
),
|
||||
)
|
||||
sqlite.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = "device-1",
|
||||
role = "node",
|
||||
token = "__openclaw_secure_prefs__",
|
||||
scopesJson = """["node.connect"]""",
|
||||
updatedAtMs = 1700000000001,
|
||||
),
|
||||
)
|
||||
val store = DeviceAuthStore(app, legacyPrefsOverride = prefs)
|
||||
|
||||
store.saveToken("device-1", "operator", "operator-token-2", scopes = listOf("operator.write"))
|
||||
|
||||
assertEquals("operator-token-2", store.loadEntry("device-1", "operator")?.token)
|
||||
assertEquals("node-token", store.loadEntry("device-1", "node")?.token)
|
||||
assertNull(prefs.getString("gateway.deviceToken.device-2.operator"))
|
||||
}
|
||||
|
||||
private fun legacyPrefs(context: Context): SecurePrefs {
|
||||
val prefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE)
|
||||
prefs.edit().clear().commit()
|
||||
return SecurePrefs(context, securePrefsOverride = prefs)
|
||||
}
|
||||
|
||||
private class ThrowingDeviceAuthStateStore : DeviceAuthStateStore {
|
||||
override fun readDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): OpenClawSQLiteDeviceAuthTokenRow? = null
|
||||
|
||||
override fun readLatestDeviceAuthDeviceId(): String? = null
|
||||
|
||||
override fun upsertDeviceAuthToken(row: OpenClawSQLiteDeviceAuthTokenRow) {
|
||||
error("sqlite unavailable")
|
||||
}
|
||||
|
||||
override fun deleteDeviceAuthToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) = Unit
|
||||
|
||||
override fun deleteAllDeviceAuthTokens() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DeviceIdentityStoreTest {
|
||||
@Before
|
||||
fun resetState() {
|
||||
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreatePersistsIdentityInSQLiteWithoutJsonSidecars() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val store = DeviceIdentityStore(app)
|
||||
|
||||
val first = store.loadOrCreate()
|
||||
val roundTripStore = DeviceIdentityStore(app)
|
||||
val second = roundTripStore.loadOrCreate()
|
||||
|
||||
assertEquals(first.deviceId, second.deviceId)
|
||||
assertEquals(first.publicKeyRawBase64, second.publicKeyRawBase64)
|
||||
val signature = roundTripStore.signPayload("payload", second)
|
||||
assertNotNull(signature)
|
||||
assertTrue(roundTripStore.verifySelfSignature("payload", signature ?: "", second))
|
||||
assertFalse(File(app.filesDir, "openclaw/identity/device.json").exists())
|
||||
assertTrue(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
|
||||
val persisted = readIdentityRow()
|
||||
assertNotNull(persisted)
|
||||
assertTrue(persisted?.contains("-----BEGIN PUBLIC KEY-----") == true)
|
||||
assertTrue(persisted?.contains(privateKeyMarker("BEGIN")) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreateReadsTypeScriptPemIdentitySchemaFromSQLite() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val publicKeyPem =
|
||||
"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
|
||||
-----END PUBLIC KEY-----
|
||||
""".trimIndent()
|
||||
val privateKeyPem =
|
||||
pemBlock(
|
||||
"PRIVATE" + " KEY",
|
||||
"MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f",
|
||||
)
|
||||
OpenClawSQLiteStateStore(app).writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
|
||||
publicKeyPem = publicKeyPem,
|
||||
privateKeyPem = privateKeyPem,
|
||||
createdAtMs = 1_700_000_000_000L,
|
||||
),
|
||||
)
|
||||
|
||||
val identity = DeviceIdentityStore(app).loadOrCreate()
|
||||
|
||||
assertEquals("56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c", identity.deviceId)
|
||||
assertEquals("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=", identity.publicKeyRawBase64)
|
||||
assertEquals("MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f", identity.privateKeyPkcs8Base64)
|
||||
assertEquals(1_700_000_000_000L, identity.createdAtMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreateRejectsSQLiteIdentityWhenPrivateKeyDoesNotMatchPublicKey() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val mismatchedPrivate = DeviceIdentityStore(app).loadOrCreate().privateKeyPkcs8Base64
|
||||
File(app.filesDir, "openclaw").deleteRecursively()
|
||||
|
||||
OpenClawSQLiteStateStore(app).writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c",
|
||||
publicKeyPem =
|
||||
"""
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
|
||||
-----END PUBLIC KEY-----
|
||||
""".trimIndent(),
|
||||
privateKeyPem = pemBlock("PRIVATE" + " KEY", mismatchedPrivate),
|
||||
createdAtMs = 1_700_000_000_000L,
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
DeviceIdentityStore(app).loadOrCreate()
|
||||
fail("Expected mismatched SQLite identity keypair to block startup")
|
||||
} catch (error: IllegalStateException) {
|
||||
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOrCreateMigratesLegacyJsonIdentityInApp() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val seed = DeviceIdentityStore(app).loadOrCreate()
|
||||
File(app.filesDir, "openclaw").deleteRecursively()
|
||||
val legacy = File(app.filesDir, "openclaw/identity/device.json")
|
||||
legacy.parentFile?.mkdirs()
|
||||
legacy.writeText(
|
||||
"""
|
||||
{
|
||||
"deviceId": "stale",
|
||||
"publicKeyRawBase64": "${seed.publicKeyRawBase64}",
|
||||
"privateKeyPkcs8Base64": "${seed.privateKeyPkcs8Base64}",
|
||||
"createdAtMs": ${seed.createdAtMs}
|
||||
}
|
||||
""".trimIndent(),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
|
||||
val migrated = DeviceIdentityStore(app).loadOrCreate()
|
||||
|
||||
assertEquals(seed.deviceId, migrated.deviceId)
|
||||
assertEquals(seed.publicKeyRawBase64, migrated.publicKeyRawBase64)
|
||||
assertFalse(legacy.exists())
|
||||
assertTrue(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidLegacyJsonIdentityFailsClosedInsteadOfRotatingIdentity() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val legacy = File(app.filesDir, "openclaw/identity/device.json")
|
||||
legacy.parentFile?.mkdirs()
|
||||
legacy.writeText("""{"deviceId":"legacy"}""", Charsets.UTF_8)
|
||||
|
||||
try {
|
||||
DeviceIdentityStore(app).loadOrCreate()
|
||||
fail("Expected invalid legacy JSON identity to block startup")
|
||||
} catch (error: IllegalStateException) {
|
||||
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readIdentityRow(): String? {
|
||||
val dbFile = File(RuntimeEnvironment.getApplication().filesDir, "openclaw/state/openclaw.sqlite")
|
||||
return SQLiteDatabase
|
||||
.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
|
||||
.use { db ->
|
||||
db
|
||||
.rawQuery(
|
||||
"SELECT public_key_pem, private_key_pem FROM device_identities WHERE identity_key = ?",
|
||||
arrayOf("default"),
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) "${cursor.getString(0)}\n${cursor.getString(1)}" else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun privateKeyMarker(boundary: String): String = "-----$boundary ${"PRIVATE" + " KEY"}-----"
|
||||
|
||||
private fun pemBlock(
|
||||
label: String,
|
||||
body: String,
|
||||
) = "-----BEGIN $label-----\n$body\n-----END $label-----"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DebugHandlerTest {
|
||||
@Test
|
||||
fun collectProcessOutputDrainsLargeStdoutBeforeWaiting() {
|
||||
val process =
|
||||
ProcessBuilder("sh", "-c", "yes openclaw-log-line | head -n 20000")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
val (finished, output) = collectProcessOutput(process, timeoutMs = 4_000, maxChars = 128_000)
|
||||
|
||||
assertTrue("expected process to finish without timing out", finished)
|
||||
assertEquals(128_000, output.length)
|
||||
assertTrue(output.startsWith("openclaw-log-line"))
|
||||
}
|
||||
}
|
||||
@@ -3,74 +3,66 @@ package ai.openclaw.app.node
|
||||
import ai.openclaw.app.NotificationBurstLimiter
|
||||
import ai.openclaw.app.NotificationForwardingPolicy
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.gateway.OpenClawSQLiteStateStore
|
||||
import ai.openclaw.app.isWithinQuietHours
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class DeviceNotificationListenerServiceTest {
|
||||
@Test
|
||||
fun recentPackages_migratesLegacyPreferenceKey() {
|
||||
@Before
|
||||
fun resetState() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.clear()
|
||||
.putString("notifications.recentPackages", "com.example.one, com.example.two")
|
||||
.commit()
|
||||
File(context.filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_readsSqliteRows() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
|
||||
listOf("com.example.one", "com.example.two"),
|
||||
)
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(listOf("com.example.one", "com.example.two"), packages)
|
||||
assertEquals(
|
||||
"com.example.one, com.example.two",
|
||||
prefs.getString("notifications.forwarding.recentPackages", null),
|
||||
)
|
||||
assertFalse(prefs.contains("notifications.recentPackages"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_cleansUpLegacyKeyWhenNewKeyAlreadyExists() {
|
||||
fun recentPackages_migratesLegacySecurePrefsRows() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
context
|
||||
.getSharedPreferences("openclaw.secure", android.content.Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.putString("notifications.forwarding.recentPackages", "com.example.new")
|
||||
.putString("notifications.recentPackages", "com.example.legacy")
|
||||
.commit()
|
||||
.putString("notifications." + "recentPackages", " com.example.legacy,com.example.other,com.example.legacy ")
|
||||
.apply()
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(listOf("com.example.new"), packages)
|
||||
assertNull(prefs.getString("notifications.recentPackages", null))
|
||||
assertEquals(listOf("com.example.legacy", "com.example.other"), packages)
|
||||
assertEquals(
|
||||
listOf("com.example.legacy", "com.example.other"),
|
||||
OpenClawSQLiteStateStore(context).readRecentNotificationPackages(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.clear()
|
||||
.putString(
|
||||
"notifications.forwarding.recentPackages",
|
||||
" com.example.recent , ,com.example.other,com.example.recent, com.example.third ",
|
||||
).commit()
|
||||
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
|
||||
listOf(" com.example.recent ", "", "com.example.other", "com.example.recent", "com.example.third"),
|
||||
)
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(
|
||||
listOf("com.example.recent", "com.example.other", "com.example.third"),
|
||||
packages,
|
||||
)
|
||||
assertEquals(listOf("com.example.recent", "com.example.other", "com.example.third"), packages)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -286,7 +286,7 @@ class InvokeDispatcherTest {
|
||||
getNodeCanvasHostUrl = { null },
|
||||
getOperatorCanvasHostUrl = { null },
|
||||
),
|
||||
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
|
||||
debugHandler = DebugHandler(DeviceIdentityStore(appContext)),
|
||||
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
|
||||
isForeground = { true },
|
||||
cameraEnabled = { cameraEnabled },
|
||||
@@ -308,7 +308,6 @@ class InvokeDispatcherTest {
|
||||
|
||||
private fun newCameraHandler(appContext: Context): CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = CameraCaptureManager(appContext),
|
||||
externalAudioCaptureActive = MutableStateFlow(false),
|
||||
showCameraHud = { _, _, _ -> },
|
||||
|
||||
@@ -2635,8 +2635,21 @@ extension NodeAppModel {
|
||||
struct SessionRow: Decodable {
|
||||
var key: String
|
||||
var updatedAt: Double?
|
||||
var deliveryContext: DeliveryContext?
|
||||
var lastChannel: String?
|
||||
var lastTo: String?
|
||||
|
||||
var deliveryChannel: String? {
|
||||
self.deliveryContext?.channel ?? self.lastChannel
|
||||
}
|
||||
|
||||
var deliveryTo: String? {
|
||||
self.deliveryContext?.to ?? self.lastTo
|
||||
}
|
||||
}
|
||||
struct DeliveryContext: Decodable {
|
||||
var channel: String?
|
||||
var to: String?
|
||||
}
|
||||
struct SessionsListResult: Decodable {
|
||||
var sessions: [SessionRow]
|
||||
@@ -2659,11 +2672,13 @@ extension NodeAppModel {
|
||||
let currentKey = self.mainSessionKey
|
||||
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
let exactMatch = sorted.first { row in
|
||||
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
row.key == currentKey
|
||||
&& normalize(row.deliveryChannel) != nil
|
||||
&& normalize(row.deliveryTo) != nil
|
||||
}
|
||||
let selected = exactMatch
|
||||
let channel = normalize(selected?.lastChannel)
|
||||
let to = normalize(selected?.lastTo)
|
||||
let channel = normalize(selected?.deliveryChannel)
|
||||
let to = normalize(selected?.deliveryTo)
|
||||
|
||||
await MainActor.run {
|
||||
self.shareDeliveryChannel = channel
|
||||
|
||||
@@ -44,6 +44,5 @@ let deepLinkKeyKey = "openclaw.deepLinkKey"
|
||||
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
|
||||
let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled"
|
||||
let debugPaneEnabledKey = "openclaw.debugPaneEnabled"
|
||||
let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled"
|
||||
let appLogLevelKey = "openclaw.debug.appLogLevel"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
|
||||
@@ -14,7 +14,7 @@ final class CronJobsStore {
|
||||
var runEntries: [CronRunLogEntry] = []
|
||||
|
||||
var schedulerEnabled: Bool?
|
||||
var schedulerStorePath: String?
|
||||
var schedulerStoreKey: String?
|
||||
var schedulerNextWakeAtMs: Int?
|
||||
|
||||
var isLoadingJobs = false
|
||||
@@ -72,7 +72,7 @@ final class CronJobsStore {
|
||||
do {
|
||||
if let status = try? await GatewayConnection.shared.cronStatus() {
|
||||
self.schedulerEnabled = status.enabled
|
||||
self.schedulerStorePath = status.storePath
|
||||
self.schedulerStoreKey = status.storeKey
|
||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||
}
|
||||
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
||||
|
||||
@@ -81,8 +81,8 @@ extension CronSettings {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
||||
Text(storePath)
|
||||
if let storeKey = self.store.schedulerStoreKey, !storeKey.isEmpty {
|
||||
Text(storeKey)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
@@ -57,7 +57,7 @@ extension CronSettings {
|
||||
static func exerciseForTesting() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
|
||||
store.schedulerStoreKey = "default"
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
|
||||
@@ -43,15 +43,15 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionStore() {
|
||||
static func openSessionDatabase() {
|
||||
if AppStateStore.shared.connectionMode == .remote {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Remote mode"
|
||||
alert.informativeText = "Session store lives on the gateway host in remote mode."
|
||||
alert.informativeText = "Session database lives on the gateway host in remote mode."
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
let path = self.resolveSessionStorePath()
|
||||
let path = self.resolveSessionDatabasePath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if FileManager().fileExists(atPath: path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
@@ -191,19 +191,8 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
let configURL = OpenClawPaths.configURL
|
||||
guard
|
||||
let data = try? Data(contentsOf: configURL),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let session = parsed["session"] as? [String: Any],
|
||||
let path = session["store"] as? String,
|
||||
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
return defaultPath
|
||||
}
|
||||
return path
|
||||
private static func resolveSessionDatabasePath() -> String {
|
||||
SessionLoader.defaultDatabasePath
|
||||
}
|
||||
|
||||
// MARK: - Sessions (thinking / verbose)
|
||||
@@ -244,8 +233,8 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionStoreInCode() {
|
||||
let path = SessionLoader.defaultStorePath
|
||||
static func openSessionDatabaseInCode() {
|
||||
let path = SessionLoader.defaultDatabasePath
|
||||
let proc = Process()
|
||||
proc.launchPath = "/usr/bin/env"
|
||||
proc.arguments = ["code", path]
|
||||
|
||||
@@ -13,8 +13,7 @@ struct DebugSettings: View {
|
||||
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
@State private var launchAgentWriteError: String?
|
||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@State private var sessionDatabasePath: String = SessionLoader.defaultDatabasePath
|
||||
@State private var debugSendInFlight = false
|
||||
@State private var debugSendStatus: String?
|
||||
@State private var debugSendError: String?
|
||||
@@ -24,7 +23,6 @@ struct DebugSettings: View {
|
||||
@State private var tunnelResetInFlight = false
|
||||
@State private var tunnelResetStatus: String?
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
|
||||
@State private var canvasSessionKey: String = "main"
|
||||
@@ -61,7 +59,7 @@ struct DebugSettings: View {
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
self.loadSessionStorePath()
|
||||
self.refreshSessionDatabasePath()
|
||||
}
|
||||
.alert(item: self.$pendingKill) { listener in
|
||||
Alert(
|
||||
@@ -284,28 +282,10 @@ struct DebugSettings: View {
|
||||
.labelsHidden()
|
||||
.help("Controls the macOS app log verbosity.")
|
||||
|
||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"Writes a rotating, local-only log under ~/Library/Logs/OpenClaw/. " +
|
||||
"Enable only while actively debugging.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Open folder") {
|
||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Clear") {
|
||||
Task { try? await DiagnosticsFileLog.shared.clear() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text(DiagnosticsFileLog.logFileURL().path)
|
||||
Text("Use Console.app or `log stream` for macOS app logs.")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,25 +401,17 @@ struct DebugSettings: View {
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Session store")
|
||||
self.gridLabel("Session database")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path", text: self.$sessionStorePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 360)
|
||||
Button("Save") { self.saveSessionStorePath() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
if let sessionStoreSaveError {
|
||||
Text(sessionStoreSaveError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Used by the CLI session loader; stored in ~/.openclaw/openclaw.json.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.sessionDatabasePath)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
Text("Runtime session state is stored in the per-agent SQLite database.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,31 +682,8 @@ struct DebugSettings: View {
|
||||
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
|
||||
}
|
||||
|
||||
private func loadSessionStorePath() {
|
||||
let parsed = OpenClawConfigFile.loadDict()
|
||||
guard
|
||||
let session = parsed["session"] as? [String: Any],
|
||||
let path = session["store"] as? String
|
||||
else {
|
||||
self.sessionStorePath = SessionLoader.defaultStorePath
|
||||
return
|
||||
}
|
||||
self.sessionStorePath = path
|
||||
}
|
||||
|
||||
private func saveSessionStorePath() {
|
||||
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var root = OpenClawConfigFile.loadDict()
|
||||
|
||||
var session = root["session"] as? [String: Any] ?? [:]
|
||||
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
|
||||
root["session"] = session
|
||||
|
||||
guard OpenClawConfigFile.saveDict(root) else {
|
||||
self.sessionStoreSaveError = "Config write rejected to protect gateway auth/mode."
|
||||
return
|
||||
}
|
||||
self.sessionStoreSaveError = nil
|
||||
private func refreshSessionDatabasePath() {
|
||||
self.sessionDatabasePath = SessionLoader.defaultDatabasePath
|
||||
}
|
||||
|
||||
private var bindingOverride: Binding<String> {
|
||||
@@ -971,8 +920,7 @@ extension DebugSettings {
|
||||
static func exerciseForTesting() async {
|
||||
let view = DebugSettings(state: .preview)
|
||||
view.gatewayRootInput = "/tmp/openclaw"
|
||||
view.sessionStorePath = "/tmp/sessions.json"
|
||||
view.sessionStoreSaveError = "Save failed"
|
||||
view.sessionDatabasePath = "/tmp/openclaw-agent.sqlite"
|
||||
view.debugSendInFlight = true
|
||||
view.debugSendStatus = "Sent"
|
||||
view.debugSendError = "Failed"
|
||||
@@ -1011,7 +959,7 @@ extension DebugSettings {
|
||||
_ = view.experimentsSection
|
||||
_ = view.gridLabel("Test")
|
||||
|
||||
view.loadSessionStorePath()
|
||||
view.refreshSessionDatabasePath()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
actor DiagnosticsFileLog {
|
||||
static let shared = DiagnosticsFileLog()
|
||||
|
||||
private let fileName = "diagnostics.jsonl"
|
||||
private let maxBytes: Int64 = 5 * 1024 * 1024
|
||||
private let maxBackups = 5
|
||||
|
||||
struct Record: Codable {
|
||||
let ts: String
|
||||
let pid: Int32
|
||||
let category: String
|
||||
let event: String
|
||||
let fields: [String: String]?
|
||||
}
|
||||
|
||||
nonisolated static func isEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
|
||||
nonisolated static func logDirectoryURL() -> URL {
|
||||
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||
return library
|
||||
.appendingPathComponent("Logs", isDirectory: true)
|
||||
.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
|
||||
nonisolated static func logFileURL() -> URL {
|
||||
self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
|
||||
}
|
||||
|
||||
nonisolated func log(category: String, event: String, fields: [String: String]? = nil) {
|
||||
guard Self.isEnabled() else { return }
|
||||
let record = Record(
|
||||
ts: ISO8601DateFormatter().string(from: Date()),
|
||||
pid: ProcessInfo.processInfo.processIdentifier,
|
||||
category: category,
|
||||
event: event,
|
||||
fields: fields)
|
||||
Task { await self.write(record: record) }
|
||||
}
|
||||
|
||||
func clear() throws {
|
||||
let fm = FileManager()
|
||||
let base = Self.logFileURL()
|
||||
if fm.fileExists(atPath: base.path) {
|
||||
try fm.removeItem(at: base)
|
||||
}
|
||||
for idx in 1...self.maxBackups {
|
||||
let url = self.rotatedURL(index: idx)
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
try fm.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func write(record: Record) {
|
||||
do {
|
||||
try self.ensureDirectory()
|
||||
try self.rotateIfNeeded()
|
||||
try self.append(record: record)
|
||||
} catch {
|
||||
// Best-effort only: never crash or block the app on logging.
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDirectory() throws {
|
||||
try FileManager().createDirectory(
|
||||
at: Self.logDirectoryURL(),
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func append(record: Record) throws {
|
||||
let url = Self.logFileURL()
|
||||
let data = try JSONEncoder().encode(record)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A) // newline
|
||||
|
||||
let fm = FileManager()
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
fm.createFile(atPath: url.path, contents: nil)
|
||||
}
|
||||
|
||||
let handle = try FileHandle(forWritingTo: url)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
}
|
||||
|
||||
private func rotateIfNeeded() throws {
|
||||
let url = Self.logFileURL()
|
||||
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber
|
||||
else { return }
|
||||
|
||||
if size.int64Value < self.maxBytes { return }
|
||||
|
||||
let fm = FileManager()
|
||||
|
||||
let oldest = self.rotatedURL(index: self.maxBackups)
|
||||
if fm.fileExists(atPath: oldest.path) {
|
||||
try fm.removeItem(at: oldest)
|
||||
}
|
||||
|
||||
if self.maxBackups > 1 {
|
||||
for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) {
|
||||
let src = self.rotatedURL(index: idx)
|
||||
let dst = self.rotatedURL(index: idx + 1)
|
||||
if fm.fileExists(atPath: src.path) {
|
||||
if fm.fileExists(atPath: dst.path) {
|
||||
try fm.removeItem(at: dst)
|
||||
}
|
||||
try fm.moveItem(at: src, to: dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first = self.rotatedURL(index: 1)
|
||||
if fm.fileExists(atPath: first.path) {
|
||||
try fm.removeItem(at: first)
|
||||
}
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
try fm.moveItem(at: url, to: first)
|
||||
}
|
||||
}
|
||||
|
||||
private func rotatedURL(index: Int) -> URL {
|
||||
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
|
||||
}
|
||||
}
|
||||
@@ -226,17 +226,20 @@ enum ExecApprovalsStore {
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
private static let defaultAutoAllowSkills = false
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
private static let fileLock = NSRecursiveLock()
|
||||
private static let storeLock = NSRecursiveLock()
|
||||
|
||||
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.fileLock.lock()
|
||||
defer { self.fileLock.unlock() }
|
||||
private static func withStoreLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.storeLock.lock()
|
||||
defer { self.storeLock.unlock() }
|
||||
return try body()
|
||||
}
|
||||
|
||||
static func fileURL() -> URL {
|
||||
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||
static func databaseURL() -> URL {
|
||||
ExecApprovalsSQLiteStateStore.databaseURL()
|
||||
}
|
||||
|
||||
static func storeLocationForDisplay() -> String {
|
||||
ExecApprovalsSQLiteStateStore.storeLocationForDisplay()
|
||||
}
|
||||
|
||||
static func socketPath() -> String {
|
||||
@@ -277,30 +280,13 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
self.withFileLock {
|
||||
let url = self.fileURL()
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsSnapshot(
|
||||
path: url.path,
|
||||
exists: false,
|
||||
hash: self.hashRaw(nil),
|
||||
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
||||
}
|
||||
let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||
let data = raw.flatMap { $0.data(using: .utf8) }
|
||||
let decoded: ExecApprovalsFile = {
|
||||
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data),
|
||||
file.version == 1
|
||||
{
|
||||
return file
|
||||
}
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}()
|
||||
self.withStoreLock {
|
||||
let raw = ExecApprovalsSQLiteStateStore.readRawState()
|
||||
return ExecApprovalsSnapshot(
|
||||
path: url.path,
|
||||
exists: true,
|
||||
path: self.storeLocationForDisplay(),
|
||||
exists: raw != nil,
|
||||
hash: self.hashRaw(raw),
|
||||
file: decoded)
|
||||
file: self.parseRawState(raw))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,54 +306,26 @@ enum ExecApprovalsStore {
|
||||
agents: file.agents)
|
||||
}
|
||||
|
||||
static func loadFile() -> ExecApprovalsFile {
|
||||
self.withFileLock {
|
||||
let url = self.fileURL()
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
if decoded.version != 1 {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
return decoded
|
||||
} catch {
|
||||
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
static func loadState() -> ExecApprovalsFile {
|
||||
self.withStoreLock {
|
||||
self.parseRawState(ExecApprovalsSQLiteStateStore.readRawState())
|
||||
}
|
||||
}
|
||||
|
||||
static func saveFile(_ file: ExecApprovalsFile) {
|
||||
self.withFileLock {
|
||||
static func saveState(_ file: ExecApprovalsFile) {
|
||||
self.withStoreLock {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let url = self.fileURL()
|
||||
self.ensureSecureStateDirectory()
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
try ExecApprovalsSQLiteStateStore.writeRawState(self.encodeRawState(file))
|
||||
} catch {
|
||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func ensureFile() -> ExecApprovalsFile {
|
||||
self.withFileLock {
|
||||
self.ensureSecureStateDirectory()
|
||||
let url = self.fileURL()
|
||||
let existed = FileManager().fileExists(atPath: url.path)
|
||||
let loaded = self.loadFile()
|
||||
let loadedHash = self.hashFile(loaded)
|
||||
|
||||
var file = self.normalizeIncoming(loaded)
|
||||
static func ensureState() -> ExecApprovalsFile {
|
||||
self.withStoreLock {
|
||||
let snapshot = self.readSnapshot()
|
||||
var file = self.normalizeIncoming(snapshot.file)
|
||||
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if path.isEmpty {
|
||||
@@ -378,26 +336,26 @@ enum ExecApprovalsStore {
|
||||
file.socket?.token = self.generateToken()
|
||||
}
|
||||
if file.agents == nil { file.agents = [:] }
|
||||
if !existed || loadedHash != self.hashFile(file) {
|
||||
self.saveFile(file)
|
||||
if !snapshot.exists || snapshot.hash != self.hashRaw(self.encodeRawState(file)) {
|
||||
self.saveState(file)
|
||||
}
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.ensureFile()
|
||||
return self.resolveFromFile(file, agentId: agentId)
|
||||
let file = self.ensureState()
|
||||
return self.resolveFromState(file, agentId: agentId)
|
||||
}
|
||||
|
||||
/// Read-only resolve: loads file without writing (no ensureFile side effects).
|
||||
/// Read-only resolve: loads SQLite state without writing missing defaults.
|
||||
/// Safe to call from background threads / off MainActor.
|
||||
static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.loadFile()
|
||||
return self.resolveFromFile(file, agentId: agentId)
|
||||
let file = self.loadState()
|
||||
return self.resolveFromState(file, agentId: agentId)
|
||||
}
|
||||
|
||||
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
|
||||
private static func resolveFromState(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -420,7 +378,7 @@ enum ExecApprovalsStore {
|
||||
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||
let token = file.socket?.token ?? ""
|
||||
return ExecApprovalsResolved(
|
||||
url: self.fileURL(),
|
||||
url: self.databaseURL(),
|
||||
socketPath: socketPath,
|
||||
token: token,
|
||||
defaults: resolvedDefaults,
|
||||
@@ -430,7 +388,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
||||
let file = self.ensureFile()
|
||||
let file = self.ensureState()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
return ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -440,13 +398,13 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
file.defaults = defaults
|
||||
}
|
||||
}
|
||||
|
||||
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
mutate(&defaults)
|
||||
file.defaults = defaults
|
||||
@@ -454,7 +412,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
var agents = file.agents ?? [:]
|
||||
let key = self.agentKey(agentId)
|
||||
if agent.isEmpty {
|
||||
@@ -476,7 +434,7 @@ enum ExecApprovalsStore {
|
||||
return reason
|
||||
}
|
||||
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -498,7 +456,7 @@ enum ExecApprovalsStore {
|
||||
command: String,
|
||||
resolvedPath: String?)
|
||||
{
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -520,7 +478,7 @@ enum ExecApprovalsStore {
|
||||
@discardableResult
|
||||
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] {
|
||||
var rejected: [ExecAllowlistRejectedEntry] = []
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -535,7 +493,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||
self.updateFile { file in
|
||||
self.updateState { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -549,28 +507,35 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||
self.withFileLock {
|
||||
var file = self.ensureFile()
|
||||
private static func updateState(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||
self.withStoreLock {
|
||||
var file = self.ensureState()
|
||||
mutate(&file)
|
||||
self.saveFile(file)
|
||||
self.saveState(file)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureSecureStateDirectory() {
|
||||
let url = OpenClawPaths.stateDirURL
|
||||
do {
|
||||
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.secureStateDirPermissions],
|
||||
ofItemAtPath: url.path)
|
||||
} catch {
|
||||
let message =
|
||||
"exec approvals state dir permission hardening failed: \(error.localizedDescription)"
|
||||
self.logger
|
||||
.warning(
|
||||
"\(message, privacy: .public)")
|
||||
private static func parseRawState(_ raw: String?) -> ExecApprovalsFile {
|
||||
guard let data = raw?.data(using: .utf8) else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
guard decoded.version == 1 else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
return decoded
|
||||
} catch {
|
||||
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
}
|
||||
|
||||
private static func encodeRawState(_ file: ExecApprovalsFile) -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = (try? encoder.encode(file)) ?? Data()
|
||||
return (String(data: data, encoding: .utf8) ?? "{}") + "\n"
|
||||
}
|
||||
|
||||
private static func generateToken() -> String {
|
||||
@@ -592,14 +557,6 @@ enum ExecApprovalsStore {
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func hashFile(_ file: ExecApprovalsFile) -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = (try? encoder.encode(file)) ?? Data()
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func expandPath(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == "~" {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum ExecApprovalsSQLiteStateStore {
|
||||
private static let configKey = "current"
|
||||
|
||||
static func databaseURL() -> URL {
|
||||
OpenClawSQLiteStateStore.databaseURL()
|
||||
}
|
||||
|
||||
static func storeLocationForDisplay() -> String {
|
||||
OpenClawSQLiteStateStore.execApprovalsLocationForDisplay(configKey: self.configKey)
|
||||
}
|
||||
|
||||
static func readRawState() -> String? {
|
||||
if let raw = OpenClawSQLiteStateStore.readExecApprovalsRaw(configKey: self.configKey) {
|
||||
return raw
|
||||
}
|
||||
guard let raw = try? String(contentsOf: self.legacyURL(), encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
try self.writeRawState(raw)
|
||||
try? FileManager.default.removeItem(at: self.legacyURL())
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
static func writeRawState(_ raw: String) throws {
|
||||
let file = self.parse(raw)
|
||||
let agents = file.agents.map { Array($0.values) } ?? []
|
||||
let allowlistCount = agents.reduce(0) { count, agent in
|
||||
count + (agent.allowlist?.count ?? 0)
|
||||
}
|
||||
try OpenClawSQLiteStateStore.writeExecApprovalsConfig(
|
||||
configKey: self.configKey,
|
||||
rawJSON: raw,
|
||||
socketPath: file.socket?.path,
|
||||
hasSocketToken: !(file.socket?.token?.isEmpty ?? true),
|
||||
defaultSecurity: file.defaults?.security?.rawValue,
|
||||
defaultAsk: file.defaults?.ask?.rawValue,
|
||||
defaultAskFallback: file.defaults?.askFallback?.rawValue,
|
||||
autoAllowSkills: file.defaults?.autoAllowSkills,
|
||||
agentCount: agents.count,
|
||||
allowlistCount: allowlistCount)
|
||||
}
|
||||
|
||||
private static func parse(_ raw: String) -> ExecApprovalsFile {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: nil)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private static func legacyURL() -> URL {
|
||||
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
@@ -766,7 +766,7 @@ extension GatewayConnection {
|
||||
|
||||
struct CronSchedulerStatus: Decodable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let storeKey: String
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
|
||||
@@ -708,7 +708,7 @@ struct GeneralSettings: View {
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
Text("Session database: \(snap.sessions.databasePath) (\(snap.sessions.count) entries)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let recent = snap.sessions.recent.first {
|
||||
|
||||
@@ -36,9 +36,37 @@ struct HealthSnapshot: Codable {
|
||||
}
|
||||
|
||||
struct Sessions: Codable {
|
||||
let path: String
|
||||
let databasePath: String
|
||||
let count: Int
|
||||
let recent: [SessionInfo]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case databasePath
|
||||
case path
|
||||
case count
|
||||
case recent
|
||||
}
|
||||
|
||||
init(databasePath: String, count: Int, recent: [SessionInfo]) {
|
||||
self.databasePath = databasePath
|
||||
self.count = count
|
||||
self.recent = recent
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.databasePath = try container.decodeIfPresent(String.self, forKey: .databasePath)
|
||||
?? container.decode(String.self, forKey: .path)
|
||||
self.count = try container.decode(Int.self, forKey: .count)
|
||||
self.recent = try container.decode([SessionInfo].self, forKey: .recent)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self.databasePath, forKey: .databasePath)
|
||||
try container.encode(self.count, forKey: .count)
|
||||
try container.encode(self.recent, forKey: .recent)
|
||||
}
|
||||
}
|
||||
|
||||
let ok: Bool?
|
||||
|
||||
@@ -20,10 +20,6 @@ enum AppLogSettings {
|
||||
static func setLogLevel(_ level: Logger.Level) {
|
||||
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
|
||||
}
|
||||
|
||||
static func fileLoggingEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppLogLevel: String, CaseIterable, Identifiable {
|
||||
@@ -60,9 +56,7 @@ enum OpenClawLogging {
|
||||
private static let didBootstrap: Void = {
|
||||
LoggingSystem.bootstrap { label in
|
||||
let (subsystem, category) = Self.parseLabel(label)
|
||||
let osHandler = OpenClawOSLogHandler(subsystem: subsystem, category: category)
|
||||
let fileHandler = OpenClawFileLogHandler(label: label)
|
||||
return MultiplexLogHandler([osHandler, fileHandler])
|
||||
return OpenClawOSLogHandler(subsystem: subsystem, category: category)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -193,65 +187,3 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
|
||||
let label: String
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
func log(event: LogEvent) {
|
||||
self.writeLog(
|
||||
level: event.level,
|
||||
message: event.message,
|
||||
metadata: event.metadata,
|
||||
source: event.source,
|
||||
file: event.file,
|
||||
function: event.function,
|
||||
line: event.line)
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
self.writeLog(
|
||||
level: level,
|
||||
message: message,
|
||||
metadata: metadata,
|
||||
source: source,
|
||||
file: file,
|
||||
function: function,
|
||||
line: line)
|
||||
}
|
||||
|
||||
private func writeLog(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
guard AppLogSettings.fileLoggingEnabled() else { return }
|
||||
let (subsystem, category) = OpenClawLogging.parseLabel(self.label)
|
||||
var fields: [String: String] = [
|
||||
"subsystem": subsystem,
|
||||
"category": category,
|
||||
"level": level.rawValue,
|
||||
"source": source,
|
||||
"file": file,
|
||||
"function": function,
|
||||
"line": "\(line)",
|
||||
]
|
||||
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||
for (key, value) in merged {
|
||||
fields["meta.\(key)"] = stringifyLogMetadataValue(value)
|
||||
}
|
||||
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ struct MenuContent: View {
|
||||
@State private var browserControlEnabled = true
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
@AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false
|
||||
|
||||
init(state: AppState, updater: UpdaterProviding?) {
|
||||
self._state = Bindable(wrappedValue: state)
|
||||
@@ -258,20 +257,13 @@ struct MenuContent: View {
|
||||
Text(level.title).tag(level.rawValue)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: self.$appFileLoggingEnabled) {
|
||||
Label(
|
||||
self.appFileLoggingEnabled
|
||||
? "File Logging: On"
|
||||
: "File Logging: Off",
|
||||
systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
} label: {
|
||||
Label("App Logging", systemImage: "doc.text")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStore()
|
||||
DebugActions.openSessionDatabase()
|
||||
} label: {
|
||||
Label("Open Session Store", systemImage: "externaldrive")
|
||||
Label("Open Session Database", systemImage: "externaldrive")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
|
||||
@@ -335,7 +335,7 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.representedObject = row.key
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.submenu = self.buildSubmenu(for: row)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
@@ -841,7 +841,7 @@ extension MenuSessionsInjector {
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Submenus
|
||||
|
||||
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
|
||||
private func buildSubmenu(for row: SessionRow) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let width = self.submenuWidth()
|
||||
|
||||
@@ -865,24 +865,6 @@ extension MenuSessionsInjector {
|
||||
verbose.submenu = self.buildVerboseMenu(for: row)
|
||||
menu.addItem(verbose)
|
||||
|
||||
if AppStateStore.shared.debugPaneEnabled,
|
||||
AppStateStore.shared.connectionMode == .local,
|
||||
let sessionId = row.sessionId,
|
||||
!sessionId.isEmpty
|
||||
{
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
let openLog = NSMenuItem(
|
||||
title: "Open Session Log",
|
||||
action: #selector(self.openSessionLog(_:)),
|
||||
keyEquivalent: "")
|
||||
openLog.target = self
|
||||
openLog.representedObject = [
|
||||
"sessionId": sessionId,
|
||||
"storePath": storePath,
|
||||
]
|
||||
menu.addItem(openLog)
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "")
|
||||
@@ -1091,15 +1073,6 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openSessionLog(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: String],
|
||||
let sessionId = dict["sessionId"],
|
||||
let storePath = dict["storePath"]
|
||||
else { return }
|
||||
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func resetSession(_ sender: NSMenuItem) {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
|
||||
@@ -817,7 +817,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: snapshot.path,
|
||||
@@ -835,7 +835,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
||||
let current = ExecApprovalsStore.ensureFile()
|
||||
let current = ExecApprovalsStore.ensureState()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
if snapshot.exists {
|
||||
if snapshot.hash.isEmpty {
|
||||
@@ -871,7 +871,7 @@ actor MacNodeRuntime {
|
||||
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
||||
|
||||
ExecApprovalsStore.saveFile(normalized)
|
||||
ExecApprovalsStore.saveState(normalized)
|
||||
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: nextSnapshot.path,
|
||||
|
||||
@@ -819,8 +819,8 @@ extension OnboardingView {
|
||||
self.featureRow(
|
||||
title: "Remote gateway checklist",
|
||||
subtitle: """
|
||||
On your gateway host: install/update the `openclaw` package and make sure credentials exist
|
||||
(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.
|
||||
On your gateway host: install/update the `openclaw` package and make sure credentials are present
|
||||
in the OpenClaw SQLite state database. Then connect again if needed.
|
||||
""",
|
||||
systemImage: "network")
|
||||
Divider()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
enum OpenClawConfigFile {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
|
||||
private static let configAuditFileName = "config-audit.jsonl"
|
||||
private static let configHealthFileName = "config-health.json"
|
||||
private static let legacyConfigHealthFileName = "config-health.json"
|
||||
private static let fileLock = NSRecursiveLock()
|
||||
private nonisolated(unsafe) static var configHealthState: [String: Any] = [:]
|
||||
|
||||
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.fileLock.lock()
|
||||
@@ -66,7 +67,6 @@ enum OpenClawConfigFile {
|
||||
let previousData = try? Data(contentsOf: url)
|
||||
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
|
||||
let previousBytes = previousData?.count
|
||||
let previousAttributes = try? FileManager().attributesOfItem(atPath: url.path)
|
||||
let hadMetaBefore = self.hasMeta(previousRoot)
|
||||
let gatewayModeBefore = self.gatewayMode(previousRoot)
|
||||
|
||||
@@ -97,88 +97,21 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
let blocking = self.configWriteBlockingReasons(suspicious)
|
||||
if !blocking.isEmpty {
|
||||
let rejectedPath = self.persistRejectedConfigWrite(data: data, configURL: url)
|
||||
_ = self.persistRejectedConfigWrite(data: data, configURL: url)
|
||||
self.logger.warning("config write rejected (\(blocking.joined(separator: ", "))) at \(url.path)")
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "rejected",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": nextBytes,
|
||||
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
|
||||
"nextDev": NSNull(),
|
||||
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
|
||||
"nextIno": NSNull(),
|
||||
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
|
||||
"nextMode": NSNull(),
|
||||
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
|
||||
"nextNlink": NSNull(),
|
||||
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
|
||||
"nextUid": NSNull(),
|
||||
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
|
||||
"nextGid": NSNull(),
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
|
||||
"preservedGatewayAuth": preservedGatewayAuth,
|
||||
"suspicious": suspicious,
|
||||
"blocking": blocking,
|
||||
"rejectedPath": rejectedPath ?? NSNull(),
|
||||
])
|
||||
return false
|
||||
}
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path)
|
||||
if !suspicious.isEmpty {
|
||||
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
|
||||
}
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "success",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": nextBytes,
|
||||
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
|
||||
"nextDev": self.fileSystemNumber(nextAttributes?[.systemNumber]) ?? NSNull(),
|
||||
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
|
||||
"nextIno": self.fileSystemNumber(nextAttributes?[.systemFileNumber]) ?? NSNull(),
|
||||
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
|
||||
"nextMode": self.posixMode(nextAttributes?[.posixPermissions]) ?? NSNull(),
|
||||
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
|
||||
"nextNlink": self.fileAttributeInt(nextAttributes?[.referenceCount]) ?? NSNull(),
|
||||
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
|
||||
"nextUid": self.fileAttributeInt(nextAttributes?[.ownerAccountID]) ?? NSNull(),
|
||||
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
|
||||
"nextGid": self.fileAttributeInt(nextAttributes?[.groupOwnerAccountID]) ?? NSNull(),
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
|
||||
"preservedGatewayAuth": preservedGatewayAuth,
|
||||
"suspicious": suspicious,
|
||||
])
|
||||
self.observeConfigRead(data: data, root: output, configURL: url, valid: true)
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "failed",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": NSNull(),
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
|
||||
"preservedGatewayAuth": preservedGatewayAuth,
|
||||
"suspicious": preservedGatewayAuth ? ["gateway-auth-preserved"] : [],
|
||||
"error": error.localizedDescription,
|
||||
])
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -471,43 +404,36 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
private static func configAuditLogURL() -> URL {
|
||||
self.stateDirURL()
|
||||
.appendingPathComponent("logs", isDirectory: true)
|
||||
.appendingPathComponent(self.configAuditFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func configHealthStateURL() -> URL {
|
||||
self.stateDirURL()
|
||||
.appendingPathComponent("logs", isDirectory: true)
|
||||
.appendingPathComponent(self.configHealthFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readConfigHealthState() -> [String: Any] {
|
||||
let url = self.configHealthStateURL()
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let persisted = OpenClawSQLiteStateStore.readConfigHealthState()
|
||||
if !persisted.isEmpty {
|
||||
self.configHealthState = persisted
|
||||
return persisted
|
||||
}
|
||||
if let legacy = self.readLegacyConfigHealthState(), !legacy.isEmpty {
|
||||
self.configHealthState = legacy
|
||||
try? OpenClawSQLiteStateStore.writeConfigHealthState(legacy)
|
||||
return legacy
|
||||
}
|
||||
return self.configHealthState
|
||||
}
|
||||
|
||||
private static func readLegacyConfigHealthState() -> [String: Any]? {
|
||||
let url = self.stateDirURL()
|
||||
.appendingPathComponent("logs", isDirectory: true)
|
||||
.appendingPathComponent(self.legacyConfigHealthFileName)
|
||||
guard FileManager().fileExists(atPath: url.path),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else {
|
||||
return [:]
|
||||
return nil
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private static func writeConfigHealthState(_ root: [String: Any]) {
|
||||
guard JSONSerialization.isValidJSONObject(root),
|
||||
let data = try? JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
else {
|
||||
return
|
||||
}
|
||||
let url = self.configHealthStateURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
self.configHealthState = root
|
||||
try? OpenClawSQLiteStateStore.writeConfigHealthState(root)
|
||||
}
|
||||
|
||||
private static func configHealthEntry(state: [String: Any], configPath: String) -> [String: Any] {
|
||||
@@ -622,16 +548,6 @@ enum OpenClawConfigFile {
|
||||
return reasons
|
||||
}
|
||||
|
||||
private static func readConfigFingerprint(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
let root = self.parseConfigData(data)
|
||||
return self.configFingerprint(
|
||||
data: data,
|
||||
root: root,
|
||||
configURL: url,
|
||||
observedAt: ISO8601DateFormatter().string(from: Date()))
|
||||
}
|
||||
|
||||
private static func configTimestampToken(_ timestamp: String) -> String {
|
||||
timestamp.replacingOccurrences(of: ":", with: "-")
|
||||
.replacingOccurrences(of: ".", with: "-")
|
||||
@@ -698,130 +614,14 @@ enum OpenClawConfigFile {
|
||||
return
|
||||
}
|
||||
|
||||
let backup = self.readConfigFingerprint(
|
||||
at: configURL.deletingLastPathComponent().appendingPathComponent("\(configURL.lastPathComponent).bak"))
|
||||
let clobberedPath = self.persistClobberedSnapshot(
|
||||
_ = self.persistClobberedSnapshot(
|
||||
data: data,
|
||||
configURL: configURL,
|
||||
observedAt: observedAt)
|
||||
self.logger.warning("config observe anomaly (\(suspicious.joined(separator: ", "))) at \(configURL.path)")
|
||||
self.appendConfigObserveAudit([
|
||||
"phase": "read",
|
||||
"configPath": configURL.path,
|
||||
"exists": true,
|
||||
"valid": valid,
|
||||
"hash": current["hash"] ?? NSNull(),
|
||||
"bytes": current["bytes"] ?? NSNull(),
|
||||
"mtimeMs": current["mtimeMs"] ?? NSNull(),
|
||||
"ctimeMs": current["ctimeMs"] ?? NSNull(),
|
||||
"dev": current["dev"] ?? NSNull(),
|
||||
"ino": current["ino"] ?? NSNull(),
|
||||
"mode": current["mode"] ?? NSNull(),
|
||||
"nlink": current["nlink"] ?? NSNull(),
|
||||
"uid": current["uid"] ?? NSNull(),
|
||||
"gid": current["gid"] ?? NSNull(),
|
||||
"hasMeta": current["hasMeta"] ?? false,
|
||||
"gatewayMode": current["gatewayMode"] ?? NSNull(),
|
||||
"suspicious": suspicious,
|
||||
"lastKnownGoodHash": lastKnownGood?["hash"] ?? NSNull(),
|
||||
"lastKnownGoodBytes": lastKnownGood?["bytes"] ?? NSNull(),
|
||||
"lastKnownGoodMtimeMs": lastKnownGood?["mtimeMs"] ?? NSNull(),
|
||||
"lastKnownGoodCtimeMs": lastKnownGood?["ctimeMs"] ?? NSNull(),
|
||||
"lastKnownGoodDev": lastKnownGood?["dev"] ?? NSNull(),
|
||||
"lastKnownGoodIno": lastKnownGood?["ino"] ?? NSNull(),
|
||||
"lastKnownGoodMode": lastKnownGood?["mode"] ?? NSNull(),
|
||||
"lastKnownGoodNlink": lastKnownGood?["nlink"] ?? NSNull(),
|
||||
"lastKnownGoodUid": lastKnownGood?["uid"] ?? NSNull(),
|
||||
"lastKnownGoodGid": lastKnownGood?["gid"] ?? NSNull(),
|
||||
"lastKnownGoodGatewayMode": lastKnownGood?["gatewayMode"] ?? NSNull(),
|
||||
"backupHash": backup?["hash"] ?? NSNull(),
|
||||
"backupBytes": backup?["bytes"] ?? NSNull(),
|
||||
"backupMtimeMs": backup?["mtimeMs"] ?? NSNull(),
|
||||
"backupCtimeMs": backup?["ctimeMs"] ?? NSNull(),
|
||||
"backupDev": backup?["dev"] ?? NSNull(),
|
||||
"backupIno": backup?["ino"] ?? NSNull(),
|
||||
"backupMode": backup?["mode"] ?? NSNull(),
|
||||
"backupNlink": backup?["nlink"] ?? NSNull(),
|
||||
"backupUid": backup?["uid"] ?? NSNull(),
|
||||
"backupGid": backup?["gid"] ?? NSNull(),
|
||||
"backupGatewayMode": backup?["gatewayMode"] ?? NSNull(),
|
||||
"clobberedPath": clobberedPath ?? NSNull(),
|
||||
])
|
||||
var nextEntry = entry
|
||||
nextEntry["lastObservedSuspiciousSignature"] = signature
|
||||
state = self.setConfigHealthEntry(state: state, configPath: configURL.path, entry: nextEntry)
|
||||
self.writeConfigHealthState(state)
|
||||
}
|
||||
|
||||
private static func appendConfigWriteAudit(_ fields: [String: Any]) {
|
||||
var record: [String: Any] = [
|
||||
"ts": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "macos-openclaw-config-file",
|
||||
"event": "config.write",
|
||||
"pid": ProcessInfo.processInfo.processIdentifier,
|
||||
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
|
||||
]
|
||||
for (key, value) in fields {
|
||||
record[key] = value is NSNull ? NSNull() : value
|
||||
}
|
||||
guard JSONSerialization.isValidJSONObject(record),
|
||||
let data = try? JSONSerialization.data(withJSONObject: record)
|
||||
else {
|
||||
return
|
||||
}
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
let logURL = self.configAuditLogURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: logURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: logURL.path) {
|
||||
FileManager().createFile(atPath: logURL.path, contents: nil)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: logURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendConfigObserveAudit(_ fields: [String: Any]) {
|
||||
var record: [String: Any] = [
|
||||
"ts": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "macos-openclaw-config-file",
|
||||
"event": "config.observe",
|
||||
"pid": ProcessInfo.processInfo.processIdentifier,
|
||||
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
|
||||
]
|
||||
for (key, value) in fields {
|
||||
record[key] = value is NSNull ? NSNull() : value
|
||||
}
|
||||
guard JSONSerialization.isValidJSONObject(record),
|
||||
let data = try? JSONSerialization.data(withJSONObject: record)
|
||||
else {
|
||||
return
|
||||
}
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
let logURL = self.configAuditLogURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: logURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: logURL.path) {
|
||||
FileManager().createFile(atPath: logURL.path, contents: nil)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: logURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
@@ -26,17 +27,9 @@ actor PortGuardian {
|
||||
#if DEBUG
|
||||
private var testingDescriptors: [Int: Descriptor] = [:]
|
||||
#endif
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}()
|
||||
|
||||
private nonisolated static var recordPath: URL {
|
||||
self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false)
|
||||
}
|
||||
|
||||
init() {
|
||||
self.records = Self.loadRecords(from: Self.recordPath)
|
||||
self.records = Self.loadRecords()
|
||||
}
|
||||
|
||||
func sweep(mode: AppState.ConnectionMode) async {
|
||||
@@ -82,7 +75,6 @@ actor PortGuardian {
|
||||
}
|
||||
|
||||
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
||||
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||
self.records.removeAll { $0.pid == pid }
|
||||
self.records.append(
|
||||
Record(
|
||||
@@ -401,16 +393,27 @@ actor PortGuardian {
|
||||
return await self.probeGatewayHealth(port: port)
|
||||
}
|
||||
|
||||
private static func loadRecords(from url: URL) -> [Record] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([Record].self, from: data)
|
||||
else { return [] }
|
||||
return decoded
|
||||
private static func loadRecords() -> [Record] {
|
||||
OpenClawSQLiteStateStore.readPortGuardianRecords().map { row in
|
||||
Record(
|
||||
port: row.port,
|
||||
pid: row.pid,
|
||||
command: row.command,
|
||||
mode: row.mode,
|
||||
timestamp: row.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(self.records) else { return }
|
||||
try? data.write(to: Self.recordPath, options: [.atomic])
|
||||
try? OpenClawSQLiteStateStore.replacePortGuardianRecords(
|
||||
self.records.map { record in
|
||||
OpenClawSQLitePortGuardianRecord(
|
||||
port: record.port,
|
||||
pid: record.pid,
|
||||
command: record.command,
|
||||
mode: record.mode,
|
||||
timestamp: record.timestamp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ enum SessionActions {
|
||||
static func deleteSession(key: String) async throws {
|
||||
_ = try await ControlChannel.shared.request(
|
||||
method: "sessions.delete",
|
||||
params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)])
|
||||
params: ["key": AnyHashable(key)])
|
||||
}
|
||||
|
||||
static func compactSession(key: String, maxLines: Int = 400) async throws {
|
||||
@@ -57,35 +57,4 @@ enum SessionActions {
|
||||
alert.alertStyle = .warning
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionLogInCode(sessionId: String, storePath: String?) {
|
||||
let candidates: [URL] = {
|
||||
var urls: [URL] = []
|
||||
if let storePath, !storePath.isEmpty {
|
||||
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
||||
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
||||
}
|
||||
urls.append(OpenClawPaths.stateDirURL.appendingPathComponent("sessions/\(sessionId).jsonl"))
|
||||
return urls
|
||||
}()
|
||||
|
||||
let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) })
|
||||
guard let url = existing else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Session log not found"
|
||||
alert.informativeText = sessionId
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
|
||||
let proc = Process()
|
||||
proc.launchPath = "/usr/bin/env"
|
||||
proc.arguments = ["code", url.path]
|
||||
if (try? proc.run()) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,38 @@ struct GatewaySessionEntryRecord: Codable {
|
||||
|
||||
struct GatewaySessionsListResponse: Codable {
|
||||
let ts: Double?
|
||||
let path: String
|
||||
let databasePath: String
|
||||
let count: Int
|
||||
let defaults: GatewaySessionDefaultsRecord?
|
||||
let sessions: [GatewaySessionEntryRecord]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ts
|
||||
case databasePath
|
||||
case path
|
||||
case count
|
||||
case defaults
|
||||
case sessions
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.ts = try container.decodeIfPresent(Double.self, forKey: .ts)
|
||||
self.databasePath = try container.decodeIfPresent(String.self, forKey: .databasePath)
|
||||
?? container.decode(String.self, forKey: .path)
|
||||
self.count = try container.decode(Int.self, forKey: .count)
|
||||
self.defaults = try container.decodeIfPresent(GatewaySessionDefaultsRecord.self, forKey: .defaults)
|
||||
self.sessions = try container.decode([GatewaySessionEntryRecord].self, forKey: .sessions)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(self.ts, forKey: .ts)
|
||||
try container.encode(self.databasePath, forKey: .databasePath)
|
||||
try container.encode(self.count, forKey: .count)
|
||||
try container.encodeIfPresent(self.defaults, forKey: .defaults)
|
||||
try container.encode(self.sessions, forKey: .sessions)
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionTokenStats {
|
||||
@@ -245,7 +273,7 @@ enum SessionLoadError: LocalizedError {
|
||||
}
|
||||
|
||||
struct SessionStoreSnapshot {
|
||||
let storePath: String
|
||||
let databasePath: String
|
||||
let defaults: SessionDefaults
|
||||
let rows: [SessionRow]
|
||||
}
|
||||
@@ -255,9 +283,9 @@ enum SessionLoader {
|
||||
static let fallbackModel = "claude-opus-4-6"
|
||||
static let fallbackContextTokens = 200_000
|
||||
|
||||
static let defaultStorePath = standardize(
|
||||
static let defaultDatabasePath = standardize(
|
||||
OpenClawPaths.stateDirURL
|
||||
.appendingPathComponent("sessions/sessions.json").path)
|
||||
.appendingPathComponent("agents/main/agent/openclaw-agent.sqlite").path)
|
||||
|
||||
static func loadSnapshot(
|
||||
activeMinutes: Int? = nil,
|
||||
@@ -326,7 +354,7 @@ enum SessionLoader {
|
||||
model: model)
|
||||
}.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
|
||||
|
||||
return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows)
|
||||
return SessionStoreSnapshot(databasePath: decoded.databasePath, defaults: defaults, rows: rows)
|
||||
}
|
||||
|
||||
static func loadRows() async throws -> [SessionRow] {
|
||||
|
||||
@@ -53,11 +53,6 @@ enum VoiceWakeChimePlayer {
|
||||
} else {
|
||||
self.logger.log(level: .info, "chime play")
|
||||
}
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [
|
||||
"reason": reason ?? "",
|
||||
"chime": chime.displayLabel,
|
||||
"systemName": chime.systemName ?? "",
|
||||
])
|
||||
SoundEffectPlayer.play(sound)
|
||||
}
|
||||
|
||||
|
||||
@@ -225,10 +225,6 @@ actor VoiceWakeRuntime {
|
||||
"voicewake runtime input preferred=\(preferred, privacy: .public) " +
|
||||
"\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public)")
|
||||
self.logger.info("voicewake runtime started")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [
|
||||
"locale": config.localeID ?? "",
|
||||
"micID": config.micID ?? "",
|
||||
])
|
||||
} catch {
|
||||
self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)")
|
||||
self.stop()
|
||||
@@ -259,7 +255,6 @@ actor VoiceWakeRuntime {
|
||||
self.activeTriggerEndTime = nil
|
||||
self.activeTriggerWord = nil
|
||||
self.logger.debug("voicewake runtime stopped")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
|
||||
|
||||
let token = self.overlayToken
|
||||
self.overlayToken = nil
|
||||
@@ -567,7 +562,6 @@ actor VoiceWakeRuntime {
|
||||
// (mirrors the push-to-talk coordination pattern).
|
||||
if config.triggersTalkMode {
|
||||
self.logger.info("voicewake trigger -> activating Talk Mode (skipping capture)")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "triggerTalkMode")
|
||||
if config.triggerChime != .none {
|
||||
await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime, reason: "voicewake.trigger") }
|
||||
}
|
||||
@@ -577,7 +571,6 @@ actor VoiceWakeRuntime {
|
||||
}
|
||||
self.listeningState = .voiceWake
|
||||
self.isCapturing = true
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
||||
self.capturedTranscript = command
|
||||
self.committedTranscript = ""
|
||||
self.volatileTranscript = command
|
||||
@@ -653,9 +646,7 @@ actor VoiceWakeRuntime {
|
||||
self.captureTask = nil
|
||||
|
||||
let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [
|
||||
"finalLen": "\(finalTranscript.count)",
|
||||
])
|
||||
self.logger.info("voicewake capture finalized len=\(finalTranscript.count)")
|
||||
// Stop further recognition events so we don't retrigger immediately with buffered audio.
|
||||
self.haltRecognitionPipeline()
|
||||
self.capturedTranscript = ""
|
||||
|
||||
@@ -76,7 +76,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
mainSessionKey: mainSessionKey)
|
||||
return OpenClawChatSessionsListResponse(
|
||||
ts: decoded.ts,
|
||||
path: decoded.path,
|
||||
databasePath: decoded.databasePath,
|
||||
count: decoded.count,
|
||||
defaults: defaults,
|
||||
sessions: decoded.sessions)
|
||||
|
||||
@@ -48,7 +48,7 @@ import Testing
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.19.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v24.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -17,16 +18,54 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file skips rewrite when unchanged`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstIdentity = try Self.fileIdentity(at: url)
|
||||
func `ensure state stores approvals in sqlite without json sidecar`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
let firstSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
let secondSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
#expect(firstSnapshot.hash == secondSnapshot.hash)
|
||||
#expect(firstSnapshot.path.contains("openclaw.sqlite#table/exec_approvals_config/current"))
|
||||
#expect(FileManager().fileExists(atPath: ExecApprovalsStore.databaseURL().path))
|
||||
#expect(!FileManager().fileExists(atPath: stateDir.appendingPathComponent("exec-approvals.json").path))
|
||||
let storedRaw = try Self.readStoredApprovalsRaw()
|
||||
#expect(storedRaw?.contains("\"version\" : 1") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure state imports legacy json approvals before sqlite defaults`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
let legacyURL = stateDir.appendingPathComponent("exec-approvals.json")
|
||||
try """
|
||||
{
|
||||
"version": 1,
|
||||
"socket": { "path": "/tmp/legacy.sock", "token": "legacy-token" },
|
||||
"defaults": { "security": "allowlist", "ask": "on-miss" },
|
||||
"agents": {
|
||||
"main": {
|
||||
"allowlist": [
|
||||
{ "id": "00000000-0000-0000-0000-000000000001", "pattern": "/usr/bin/rg" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
""".write(to: legacyURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let ensured = ExecApprovalsStore.ensureState()
|
||||
|
||||
#expect(ensured.socket?.path == "/tmp/legacy.sock")
|
||||
#expect(ensured.socket?.token == "legacy-token")
|
||||
#expect(ensured.defaults?.security == .allowlist)
|
||||
#expect(ensured.defaults?.ask == .onMiss)
|
||||
#expect(ensured.agents?["main"]?.allowlist?.map(\.pattern) == ["/usr/bin/rg"])
|
||||
#expect(!FileManager().fileExists(atPath: legacyURL.path))
|
||||
let storedRaw = try Self.readStoredApprovalsRaw()
|
||||
#expect(storedRaw?.contains("legacy-token") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,24 +105,38 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file hardens state directory permissions`() async throws {
|
||||
func `ensure state hardens state directory permissions`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path)
|
||||
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
let attrs = try FileManager().attributesOfItem(atPath: stateDir.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
|
||||
#expect(permissions & 0o777 == 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileIdentity(at url: URL) throws -> Int {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||
struct MissingIdentifierError: Error {}
|
||||
throw MissingIdentifierError()
|
||||
private static func readStoredApprovalsRaw() throws -> String? {
|
||||
var db: OpaquePointer?
|
||||
guard sqlite3_open_v2(ExecApprovalsStore.databaseURL().path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK
|
||||
else {
|
||||
defer { sqlite3_close(db) }
|
||||
throw NSError(domain: "ExecApprovalsStoreRefactorTests", code: 1)
|
||||
}
|
||||
return identifier
|
||||
defer { sqlite3_close(db) }
|
||||
|
||||
let sql = "SELECT raw_json FROM exec_approvals_config WHERE config_key = 'current'"
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
defer { sqlite3_finalize(statement) }
|
||||
throw NSError(domain: "ExecApprovalsStoreRefactorTests", code: 2)
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
guard sqlite3_step(statement) == SQLITE_ROW, let rawText = sqlite3_column_text(statement, 0) else {
|
||||
return nil
|
||||
}
|
||||
return String(cString: UnsafeRawPointer(rawText).assumingMemoryBound(to: CChar.self))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Testing
|
||||
struct HealthDecodeTests {
|
||||
private let sampleJSON: String = // minimal but complete payload
|
||||
"""
|
||||
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"databasePath":"/tmp/openclaw-agent.sqlite","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
"""
|
||||
|
||||
@Test func `decodes clean JSON`() {
|
||||
@@ -29,4 +29,22 @@ struct HealthDecodeTests {
|
||||
|
||||
#expect(snap == nil)
|
||||
}
|
||||
|
||||
@Test func `sessions list decodes legacy path field`() throws {
|
||||
let data = Data(
|
||||
#"{"ts":1733622000,"path":"/tmp/sessions.json","count":0,"sessions":[]}"#.utf8)
|
||||
let decoded = try JSONDecoder().decode(GatewaySessionsListResponse.self, from: data)
|
||||
|
||||
#expect(decoded.databasePath == "/tmp/sessions.json")
|
||||
}
|
||||
|
||||
@Test func `health sessions decode legacy path field`() {
|
||||
let data = Data(
|
||||
"""
|
||||
{"ts":1733622000,"durationMs":420,"channels":{},"channelOrder":[],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":0,"recent":[]}}
|
||||
""".utf8)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
|
||||
#expect(snap?.sessions.databasePath == "/tmp/sessions.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ struct HealthStoreStateTests {
|
||||
channelOrder: ["whatsapp"],
|
||||
channelLabels: ["whatsapp": "WhatsApp"],
|
||||
heartbeatSeconds: 60,
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
|
||||
sessions: .init(databasePath: "/tmp/openclaw-agent.sqlite", count: 0, recent: []))
|
||||
|
||||
let store = HealthStore.shared
|
||||
store.__setSnapshotForTest(snap, lastError: nil)
|
||||
|
||||
@@ -82,7 +82,7 @@ struct MenuSessionsInjectorTests {
|
||||
model: "claude-opus-4-6"),
|
||||
]
|
||||
let snapshot = SessionStoreSnapshot(
|
||||
storePath: "/tmp/sessions.json",
|
||||
databasePath: "/tmp/openclaw-agent.sqlite",
|
||||
defaults: defaults,
|
||||
rows: rows)
|
||||
injector.setTestingSnapshot(snapshot, errorText: nil)
|
||||
@@ -97,7 +97,7 @@ struct MenuSessionsInjectorTests {
|
||||
plan: "Pro",
|
||||
error: nil),
|
||||
GatewayUsageProvider(
|
||||
provider: "openai-codex",
|
||||
provider: "openai",
|
||||
displayName: "Codex",
|
||||
windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)],
|
||||
plan: nil,
|
||||
|
||||
@@ -11,6 +11,23 @@ struct OpenClawConfigFileTests {
|
||||
.path
|
||||
}
|
||||
|
||||
private func legacyConfigSidecarURLs(in stateDir: URL) -> (audit: URL, health: URL) {
|
||||
let logsDir = stateDir.appendingPathComponent("logs", isDirectory: true)
|
||||
return (
|
||||
logsDir.appendingPathComponent("config-audit.jsonl"),
|
||||
logsDir.appendingPathComponent("config-health.json")
|
||||
)
|
||||
}
|
||||
|
||||
private func configRecoveryFile(
|
||||
in directory: URL,
|
||||
configName: String,
|
||||
marker: String) throws -> URL?
|
||||
{
|
||||
try FileManager().contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
|
||||
.first { $0.lastPathComponent.hasPrefix("\(configName).\(marker).") }
|
||||
}
|
||||
|
||||
@Test
|
||||
func `config path respects env override`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
@@ -121,11 +138,11 @@ struct OpenClawConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict appends config audit log`() async throws {
|
||||
func `save dict does not write config state sidecars`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -140,25 +157,8 @@ struct OpenClawConfigFileTests {
|
||||
let configData = try Data(contentsOf: configPath)
|
||||
let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
|
||||
#expect((configRoot?["meta"] as? [String: Any]) != nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
#expect(!lines.isEmpty)
|
||||
guard let last = lines.last else {
|
||||
Issue.record("Missing config audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["event"] as? String == "config.write")
|
||||
#expect(auditRoot?["result"] as? String == "success")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
#expect(auditRoot?["previousMode"] is NSNull)
|
||||
#expect(auditRoot?["nextMode"] is NSNumber)
|
||||
#expect(auditRoot?["previousIno"] is NSNull)
|
||||
#expect(auditRoot?["nextIno"] as? String != nil)
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +268,11 @@ struct OpenClawConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `load dict audits suspicious out-of-band clobbers`() async throws {
|
||||
func `load dict preserves suspicious out-of-band clobbers without state sidecars`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -306,31 +306,16 @@ struct OpenClawConfigFileTests {
|
||||
let loaded = OpenClawConfigFile.loadDict()
|
||||
#expect((loaded["gateway"] as? [String: Any]) == nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
let observeLine = lines.reversed().first { $0.contains("\"event\":\"config.observe\"") }
|
||||
#expect(observeLine != nil)
|
||||
guard let observeLine else {
|
||||
Issue.record("Missing config.observe audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
#expect(auditRoot?["mode"] is NSNumber)
|
||||
#expect(auditRoot?["ino"] as? String != nil)
|
||||
#expect(auditRoot?["lastKnownGoodMode"] is NSNumber)
|
||||
#expect(auditRoot?["backupMode"] is NSNull)
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-mode-missing-vs-last-good"))
|
||||
#expect(suspicious.contains("update-channel-only-root"))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
|
||||
let clobberedPath = auditRoot?["clobberedPath"] as? String
|
||||
#expect(clobberedPath != nil)
|
||||
if let clobberedPath {
|
||||
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
|
||||
let clobberedURL = try self.configRecoveryFile(
|
||||
in: configPath.deletingLastPathComponent(),
|
||||
configName: configPath.lastPathComponent,
|
||||
marker: "clobbered")
|
||||
#expect(clobberedURL != nil)
|
||||
if let clobberedURL {
|
||||
let preserved = try String(contentsOf: clobberedURL, encoding: .utf8)
|
||||
#expect(preserved == clobbered)
|
||||
}
|
||||
}
|
||||
@@ -339,11 +324,61 @@ struct OpenClawConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict records preserved gateway auth in audit`() async throws {
|
||||
func `load dict imports legacy config health baseline`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try FileManager().createDirectory(
|
||||
at: sidecars.health.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let legacyHealth: [String: Any] = [
|
||||
"entries": [
|
||||
configPath.path: [
|
||||
"lastKnownGood": [
|
||||
"hash": "previous-good",
|
||||
"bytes": 1024,
|
||||
"hasMeta": true,
|
||||
"gatewayMode": "local",
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: legacyHealth, options: [.prettyPrinted])
|
||||
try legacyData.write(to: sidecars.health)
|
||||
try """
|
||||
{
|
||||
"update": {
|
||||
"channel": "beta"
|
||||
}
|
||||
}
|
||||
""".write(to: configPath, atomically: true, encoding: .utf8)
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
let loaded = OpenClawConfigFile.loadDict()
|
||||
#expect(((loaded["update"] as? [String: Any])?["channel"] as? String) == "beta")
|
||||
|
||||
let clobberedURL = try self.configRecoveryFile(
|
||||
in: configPath.deletingLastPathComponent(),
|
||||
configName: configPath.lastPathComponent,
|
||||
marker: "clobbered")
|
||||
#expect(clobberedURL != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict preserves gateway auth without audit sidecar`() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -379,14 +414,8 @@ struct OpenClawConfigFileTests {
|
||||
#expect(auth?["mode"] as? String == "token")
|
||||
#expect(auth?["token"] as? String == "test-token") // pragma: allowlist secret
|
||||
#expect((root?["meta"] as? [String: Any]) != nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let last = rawAudit.split(whereSeparator: \.isNewline).map(String.init).last
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data((last ?? "{}").utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["result"] as? String == "success")
|
||||
#expect(auditRoot?["preservedGatewayAuth"] as? Bool == true)
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-auth-preserved"))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +425,7 @@ struct OpenClawConfigFileTests {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -428,21 +457,16 @@ struct OpenClawConfigFileTests {
|
||||
let after = try String(contentsOf: configPath, encoding: .utf8)
|
||||
#expect(after == before)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit.split(whereSeparator: \.isNewline).map(String.init)
|
||||
guard let last = lines.last else {
|
||||
Issue.record("Missing rejected config audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["result"] as? String == "rejected")
|
||||
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
|
||||
let blocking = auditRoot?["blocking"] as? [String] ?? []
|
||||
#expect(suspicious.contains("gateway-mode-removed"))
|
||||
#expect(blocking.contains("gateway-mode-removed"))
|
||||
if let rejectedPath = auditRoot?["rejectedPath"] as? String {
|
||||
#expect(FileManager().fileExists(atPath: rejectedPath))
|
||||
let attributes = try FileManager().attributesOfItem(atPath: rejectedPath)
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
|
||||
let rejectedURL = try self.configRecoveryFile(
|
||||
in: configPath.deletingLastPathComponent(),
|
||||
configName: configPath.lastPathComponent,
|
||||
marker: "rejected")
|
||||
if let rejectedURL {
|
||||
#expect(FileManager().fileExists(atPath: rejectedURL.path))
|
||||
let attributes = try FileManager().attributesOfItem(atPath: rejectedURL.path)
|
||||
let mode = attributes[.posixPermissions] as? NSNumber
|
||||
#expect(mode?.intValue == 0o600)
|
||||
} else {
|
||||
|
||||
@@ -9,7 +9,7 @@ struct SettingsViewSmokeTests {
|
||||
@Test func `cron settings builds body`() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
|
||||
store.schedulerStoreKey = "default"
|
||||
|
||||
let job1 = CronJob(
|
||||
id: "job-1",
|
||||
|
||||
@@ -25,8 +25,8 @@ import Testing
|
||||
let entry = VoiceWakeForwarder.SessionRouteEntry(
|
||||
key: "agent:main:telegram:group:6812765697",
|
||||
channel: "telegram",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "telegram:6812765697",
|
||||
lastChannel: nil,
|
||||
lastTo: nil,
|
||||
deliveryContext: .init(channel: "telegram", to: "telegram:6812765697"))
|
||||
|
||||
let opts = VoiceWakeForwarder.forwardOptions(
|
||||
@@ -41,6 +41,23 @@ import Testing
|
||||
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
|
||||
}
|
||||
|
||||
@Test func `selected forward options keep legacy session route fallback`() {
|
||||
let entry = VoiceWakeForwarder.SessionRouteEntry(
|
||||
key: "legacy-session",
|
||||
channel: nil,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "telegram:6812765697",
|
||||
deliveryContext: nil)
|
||||
|
||||
let opts = VoiceWakeForwarder.forwardOptions(
|
||||
sessionKey: entry.key,
|
||||
routeEntry: entry)
|
||||
|
||||
#expect(opts.channel == .telegram)
|
||||
#expect(opts.to == "telegram:6812765697")
|
||||
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
|
||||
}
|
||||
|
||||
@Test func `selected forward options parse channel scoped session fallback`() {
|
||||
let opts = VoiceWakeForwarder.forwardOptions(
|
||||
sessionKey: "agent:main:discord:channel:123:456",
|
||||
|
||||
@@ -13,6 +13,10 @@ let package = Package(
|
||||
.library(name: "OpenClawKit", targets: ["OpenClawKit"]),
|
||||
.library(name: "OpenClawChatUI", targets: ["OpenClawChatUI"]),
|
||||
],
|
||||
traits: [
|
||||
.trait(name: "Talk", description: "ElevenLabs cloud TTS / talk support"),
|
||||
.default(enabledTraits: ["Talk"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"),
|
||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
|
||||
@@ -28,7 +32,7 @@ let package = Package(
|
||||
name: "OpenClawKit",
|
||||
dependencies: [
|
||||
"OpenClawProtocol",
|
||||
.product(name: "ElevenLabsKit", package: "ElevenLabsKit"),
|
||||
.product(name: "ElevenLabsKit", package: "ElevenLabsKit", condition: .when(traits: ["Talk"])),
|
||||
],
|
||||
path: "Sources/OpenClawKit",
|
||||
resources: [
|
||||
|
||||
@@ -153,20 +153,20 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
|
||||
|
||||
public struct OpenClawChatSessionsListResponse: Codable, Sendable {
|
||||
public let ts: Double?
|
||||
public let path: String?
|
||||
public let databasePath: String?
|
||||
public let count: Int?
|
||||
public let defaults: OpenClawChatSessionsDefaults?
|
||||
public let sessions: [OpenClawChatSessionEntry]
|
||||
|
||||
public init(
|
||||
ts: Double?,
|
||||
path: String?,
|
||||
databasePath: String?,
|
||||
count: Int?,
|
||||
defaults: OpenClawChatSessionsDefaults?,
|
||||
sessions: [OpenClawChatSessionEntry])
|
||||
{
|
||||
self.ts = ts
|
||||
self.path = path
|
||||
self.databasePath = databasePath
|
||||
self.count = count
|
||||
self.defaults = defaults
|
||||
self.sessions = sessions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Talk
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
@@ -14,3 +15,4 @@ public protocol PCMStreamingAudioPlaying {
|
||||
|
||||
extension StreamingAudioPlayer: StreamingAudioPlaying {}
|
||||
extension PCMStreamingAudioPlayer: PCMStreamingAudioPlaying {}
|
||||
#endif
|
||||
|
||||
@@ -21,12 +21,14 @@ private struct DeviceAuthStoreFile: Codable {
|
||||
}
|
||||
|
||||
public enum DeviceAuthStore {
|
||||
private static let fileName = "device-auth.json"
|
||||
private static let legacyFileName = "device-auth.json"
|
||||
private static let androidSecurePrefsTokenMarker = "__openclaw_secure_prefs__"
|
||||
|
||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||
let role = self.normalizeRole(role)
|
||||
return store.tokens[role]
|
||||
guard let row = OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: deviceId, role: role)
|
||||
else { return self.loadLegacyTokenIfNoSQLiteAuthRows(deviceId: deviceId, role: role) }
|
||||
return self.entry(from: row)
|
||||
}
|
||||
|
||||
public static func storeToken(
|
||||
@@ -36,31 +38,48 @@ public enum DeviceAuthStore {
|
||||
scopes: [String] = []) -> DeviceAuthEntry
|
||||
{
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
var next = self.readStore()
|
||||
if next?.deviceId != deviceId {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
let entry = DeviceAuthEntry(
|
||||
token: token,
|
||||
role: normalizedRole,
|
||||
scopes: normalizeScopes(scopes),
|
||||
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
self.writeStore(store)
|
||||
let currentDeviceId = OpenClawSQLiteStateStore.readLatestDeviceAuthDeviceId()
|
||||
let legacyStoreToImport =
|
||||
currentDeviceId == nil ? self.readLegacyStore().flatMap { $0.deviceId == deviceId ? $0 : nil } : nil
|
||||
let sqliteDeviceChanged = currentDeviceId != nil && currentDeviceId != deviceId
|
||||
let shouldDropLegacyStore =
|
||||
sqliteDeviceChanged || self.readLegacyStore().map { $0.deviceId != deviceId } == true
|
||||
do {
|
||||
if sqliteDeviceChanged {
|
||||
try OpenClawSQLiteStateStore.deleteAllDeviceAuthTokens()
|
||||
}
|
||||
if let legacyStoreToImport {
|
||||
for legacyEntry in legacyStoreToImport.tokens.values {
|
||||
let normalized = self.normalizedLegacyEntry(legacyEntry)
|
||||
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(
|
||||
self.row(deviceId: deviceId, entry: normalized))
|
||||
}
|
||||
}
|
||||
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(self.row(deviceId: deviceId, entry: entry))
|
||||
if shouldDropLegacyStore || legacyStoreToImport != nil {
|
||||
self.removeLegacyStore()
|
||||
} else {
|
||||
self.removeLegacyToken(deviceId: deviceId, role: normalizedRole)
|
||||
}
|
||||
} catch {
|
||||
var fallback =
|
||||
self.readLegacyStore().flatMap { $0.deviceId == deviceId ? $0 : nil }
|
||||
?? DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
fallback.tokens[normalizedRole] = entry
|
||||
self.writeLegacyStore(fallback)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
public static func clearToken(deviceId: String, role: String) {
|
||||
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
guard store.tokens[normalizedRole] != nil else { return }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
self.writeStore(store)
|
||||
try? OpenClawSQLiteStateStore.deleteDeviceAuthToken(deviceId: deviceId, role: normalizedRole)
|
||||
self.removeLegacyToken(deviceId: deviceId, role: normalizedRole)
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
@@ -74,24 +93,71 @@ public enum DeviceAuthStore {
|
||||
return Array(Set(trimmed)).sorted()
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
private static func entry(from row: OpenClawSQLiteDeviceAuthTokenRow) -> DeviceAuthEntry? {
|
||||
guard row.token != self.androidSecurePrefsTokenMarker else { return nil }
|
||||
return DeviceAuthEntry(
|
||||
token: row.token,
|
||||
role: row.role,
|
||||
scopes: self.decodeScopes(row.scopesJSON),
|
||||
updatedAtMs: row.updatedAtMs)
|
||||
}
|
||||
|
||||
private static func readStore() -> DeviceAuthStoreFile? {
|
||||
let url = self.fileURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
guard decoded.version == 1 else { return nil }
|
||||
private static func normalizedLegacyEntry(_ entry: DeviceAuthEntry) -> DeviceAuthEntry {
|
||||
DeviceAuthEntry(
|
||||
token: entry.token,
|
||||
role: self.normalizeRole(entry.role),
|
||||
scopes: self.normalizeScopes(entry.scopes),
|
||||
updatedAtMs: entry.updatedAtMs)
|
||||
}
|
||||
|
||||
private static func row(deviceId: String, entry: DeviceAuthEntry) -> OpenClawSQLiteDeviceAuthTokenRow {
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId: deviceId,
|
||||
role: entry.role,
|
||||
token: entry.token,
|
||||
scopesJSON: self.encodeScopes(entry.scopes),
|
||||
updatedAtMs: entry.updatedAtMs)
|
||||
}
|
||||
|
||||
private static func encodeScopes(_ scopes: [String]) -> String {
|
||||
guard let data = try? JSONEncoder().encode(scopes),
|
||||
let raw = String(data: data, encoding: .utf8)
|
||||
else { return "[]" }
|
||||
return raw
|
||||
}
|
||||
|
||||
private static func decodeScopes(_ raw: String) -> [String] {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode([String].self, from: data)
|
||||
else { return [] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = self.fileURL()
|
||||
private static func loadLegacyTokenIfNoSQLiteAuthRows(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard OpenClawSQLiteStateStore.readLatestDeviceAuthDeviceId() == nil else {
|
||||
self.removeLegacyToken(deviceId: deviceId, role: role)
|
||||
return nil
|
||||
}
|
||||
return self.importLegacyTokenIfNeeded(deviceId: deviceId, role: role)
|
||||
}
|
||||
|
||||
private static func legacyFileURL() -> URL {
|
||||
DeviceIdentityPaths.legacyStateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.legacyFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func readLegacyStore() -> DeviceAuthStoreFile? {
|
||||
let url = self.legacyFileURL()
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data),
|
||||
decoded.version == 1
|
||||
else { return nil }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeLegacyStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = self.legacyFileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
@@ -103,4 +169,35 @@ public enum DeviceAuthStore {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private static func importLegacyTokenIfNeeded(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = self.readLegacyStore(), store.deviceId == deviceId else { return nil }
|
||||
do {
|
||||
for entry in store.tokens.values {
|
||||
let normalized = self.normalizedLegacyEntry(entry)
|
||||
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(self.row(deviceId: deviceId, entry: normalized))
|
||||
}
|
||||
try FileManager.default.removeItem(at: self.legacyFileURL())
|
||||
} catch {
|
||||
return store.tokens[role].map(self.normalizedLegacyEntry)
|
||||
}
|
||||
guard let row = OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: deviceId, role: role) else {
|
||||
return nil
|
||||
}
|
||||
return self.entry(from: row)
|
||||
}
|
||||
|
||||
private static func removeLegacyToken(deviceId: String, role: String) {
|
||||
guard var store = self.readLegacyStore(), store.deviceId == deviceId else { return }
|
||||
store.tokens.removeValue(forKey: role)
|
||||
if store.tokens.isEmpty {
|
||||
self.removeLegacyStore()
|
||||
} else {
|
||||
self.writeLegacyStore(store)
|
||||
}
|
||||
}
|
||||
|
||||
private static func removeLegacyStore() {
|
||||
try? FileManager.default.removeItem(at: self.legacyFileURL())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
public struct DeviceIdentity: Codable, Sendable {
|
||||
public var deviceId: String
|
||||
@@ -17,8 +18,17 @@ public struct DeviceIdentity: Codable, Sendable {
|
||||
|
||||
enum DeviceIdentityPaths {
|
||||
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
|
||||
#if DEBUG
|
||||
nonisolated(unsafe) static var testingStateDirURL: URL?
|
||||
#endif
|
||||
|
||||
static func stateDirURL() -> URL {
|
||||
#if DEBUG
|
||||
if let testingStateDirURL {
|
||||
return testingStateDirURL
|
||||
}
|
||||
#endif
|
||||
|
||||
for key in self.stateDirEnv {
|
||||
if let raw = getenv(key) {
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -28,7 +38,30 @@ enum DeviceIdentityPaths {
|
||||
}
|
||||
}
|
||||
|
||||
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
}
|
||||
|
||||
static func legacyStateDirURL() -> URL {
|
||||
#if DEBUG
|
||||
if let testingStateDirURL {
|
||||
return testingStateDirURL
|
||||
}
|
||||
#endif
|
||||
|
||||
for key in self.stateDirEnv {
|
||||
if let raw = getenv(key) {
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return URL(fileURLWithPath: value, isDirectory: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let appSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first
|
||||
{
|
||||
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
|
||||
@@ -37,7 +70,7 @@ enum DeviceIdentityPaths {
|
||||
}
|
||||
|
||||
public enum DeviceIdentityStore {
|
||||
private static let fileName = "device.json"
|
||||
private static let identityKey = "default"
|
||||
private static let ed25519SPKIPrefix = Data([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
|
||||
0x70, 0x03, 0x21, 0x00,
|
||||
@@ -48,56 +81,140 @@ public enum DeviceIdentityStore {
|
||||
])
|
||||
|
||||
public static func loadOrCreate() -> DeviceIdentity {
|
||||
self.loadOrCreate(fileURL: self.fileURL())
|
||||
}
|
||||
|
||||
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
|
||||
if let data = try? Data(contentsOf: url) {
|
||||
switch self.decodeStoredIdentity(data) {
|
||||
let row: OpenClawSQLiteDeviceIdentityRow?
|
||||
do {
|
||||
row = try self.readStoredIdentityRow()
|
||||
} catch {
|
||||
preconditionFailure(
|
||||
"OpenClaw device identity SQLite read failed: \(error.localizedDescription). " +
|
||||
"Run openclaw doctor --fix before starting runtime.")
|
||||
}
|
||||
if let row {
|
||||
switch self.decodeStoredIdentity(self.storedIdentity(from: row)) {
|
||||
case .identity(let decoded):
|
||||
return decoded
|
||||
case .recognizedInvalid:
|
||||
return self.generate()
|
||||
case .unknown:
|
||||
break
|
||||
preconditionFailure("Stored OpenClaw device identity is invalid. Run openclaw doctor --fix.")
|
||||
}
|
||||
}
|
||||
switch self.loadLegacyIdentity() {
|
||||
case .some(.identity(let identity)):
|
||||
if self.save(identity) {
|
||||
try? FileManager.default.removeItem(at: self.legacyIdentityURL())
|
||||
}
|
||||
return identity
|
||||
case .some(.recognizedInvalid):
|
||||
preconditionFailure(
|
||||
"Legacy OpenClaw device identity exists at \(self.legacyIdentityURL().path). " +
|
||||
"Run openclaw doctor --fix before starting runtime.")
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
let identity = self.generate()
|
||||
self.save(identity, to: url)
|
||||
_ = self.save(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
static func legacyIdentityMigrationRequired() -> Bool {
|
||||
FileManager.default.fileExists(atPath: self.legacyIdentityURL().path)
|
||||
}
|
||||
|
||||
private static func legacyIdentityURL() -> URL {
|
||||
DeviceIdentityPaths.legacyStateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device.json", isDirectory: false)
|
||||
}
|
||||
|
||||
private enum DecodeResult {
|
||||
case identity(DeviceIdentity)
|
||||
case recognizedInvalid
|
||||
case unknown
|
||||
}
|
||||
|
||||
private static func decodeStoredIdentity(_ data: Data) -> DecodeResult {
|
||||
private static func readStoredIdentityRow() throws -> OpenClawSQLiteDeviceIdentityRow? {
|
||||
try self.withSQLiteRetry {
|
||||
try OpenClawSQLiteStateStore.readDeviceIdentityChecked(key: self.identityKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func withSQLiteRetry<T>(_ operation: () throws -> T) throws -> T {
|
||||
var lastError: Error?
|
||||
for attempt in 0..<3 {
|
||||
do {
|
||||
return try operation()
|
||||
} catch {
|
||||
lastError = error
|
||||
guard attempt < 2, self.isTransientSQLiteError(error) else {
|
||||
throw error
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.05 * Double(attempt + 1))
|
||||
}
|
||||
}
|
||||
throw lastError ?? NSError(
|
||||
domain: "OpenClawSQLiteStateStore",
|
||||
code: Int(SQLITE_ERROR),
|
||||
userInfo: [NSLocalizedDescriptionKey: "SQLite operation failed"])
|
||||
}
|
||||
|
||||
private static func isTransientSQLiteError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
guard nsError.domain == "OpenClawSQLiteStateStore" else { return false }
|
||||
return nsError.code == Int(SQLITE_BUSY) || nsError.code == Int(SQLITE_LOCKED)
|
||||
}
|
||||
|
||||
private static func storedIdentity(from row: OpenClawSQLiteDeviceIdentityRow) -> StoredDeviceIdentity {
|
||||
StoredDeviceIdentity(
|
||||
version: 1,
|
||||
deviceId: row.deviceId,
|
||||
publicKeyPem: row.publicKeyPem,
|
||||
privateKeyPem: row.privateKeyPem,
|
||||
createdAtMs: row.createdAtMs)
|
||||
}
|
||||
|
||||
private static func decodeStoredIdentity(_ decoded: StoredDeviceIdentity) -> DecodeResult {
|
||||
guard decoded.version == 1,
|
||||
let publicKeyData = self.rawPublicKey(fromPEM: decoded.publicKeyPem),
|
||||
let privateKeyData = self.rawPrivateKey(fromPEM: decoded.privateKeyPem),
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
return .identity(DeviceIdentity(
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: decoded.createdAtMs))
|
||||
}
|
||||
|
||||
private static func loadLegacyIdentity() -> DecodeResult? {
|
||||
let url = self.legacyIdentityURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
return self.decodeLegacyIdentity(data)
|
||||
}
|
||||
|
||||
private static func decodeLegacyIdentity(_ data: Data) -> DecodeResult {
|
||||
let decoder = JSONDecoder()
|
||||
if let decoded = try? decoder.decode(DeviceIdentity.self, from: data) {
|
||||
guard let identity = self.normalizedRawIdentity(decoded) else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
return .identity(identity)
|
||||
if let stored = try? decoder.decode(StoredDeviceIdentity.self, from: data) {
|
||||
return self.decodeStoredIdentity(stored)
|
||||
}
|
||||
|
||||
if let decoded = try? decoder.decode(PemDeviceIdentity.self, from: data) {
|
||||
guard decoded.version == 1,
|
||||
let publicKeyData = self.rawPublicKey(fromPEM: decoded.publicKeyPem),
|
||||
let privateKeyData = self.rawPrivateKey(fromPEM: decoded.privateKeyPem),
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
return .identity(DeviceIdentity(
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: decoded.createdAtMs))
|
||||
guard let decoded = try? decoder.decode(DeviceIdentity.self, from: data),
|
||||
let publicKeyData = Data(base64Encoded: decoded.publicKey),
|
||||
let privateKeyData = Data(base64Encoded: decoded.privateKey),
|
||||
publicKeyData.count == 32,
|
||||
privateKeyData.count == 32,
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else {
|
||||
return .recognizedInvalid
|
||||
}
|
||||
|
||||
return self.hasRecognizedIdentityShape(data) ? .recognizedInvalid : .unknown
|
||||
return .identity(DeviceIdentity(
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: decoded.createdAtMs))
|
||||
}
|
||||
|
||||
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
@@ -137,22 +254,6 @@ public enum DeviceIdentityStore {
|
||||
return self.base64UrlEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizedRawIdentity(_ identity: DeviceIdentity) -> DeviceIdentity? {
|
||||
guard !identity.deviceId.isEmpty,
|
||||
let publicKeyData = Data(base64Encoded: identity.publicKey),
|
||||
let privateKeyData = Data(base64Encoded: identity.privateKey)
|
||||
else { return nil }
|
||||
|
||||
guard publicKeyData.count == 32 && privateKeyData.count == 32,
|
||||
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
|
||||
else { return nil }
|
||||
return DeviceIdentity(
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKey: identity.publicKey,
|
||||
privateKey: identity.privateKey,
|
||||
createdAtMs: identity.createdAtMs)
|
||||
}
|
||||
|
||||
private static func rawPublicKey(fromPEM pem: String) -> Data? {
|
||||
guard let der = self.derData(fromPEM: pem),
|
||||
der.count == self.ed25519SPKIPrefix.count + 32,
|
||||
@@ -185,41 +286,55 @@ public enum DeviceIdentityStore {
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
|
||||
private static func hasRecognizedIdentityShape(_ data: Data) -> Bool {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return false
|
||||
}
|
||||
return object.keys.contains("publicKeyPem")
|
||||
|| object.keys.contains("privateKeyPem")
|
||||
|| object.keys.contains("publicKey")
|
||||
|| object.keys.contains("privateKey")
|
||||
private static func pem(label: String, der: Data) -> String {
|
||||
let chunks = stride(from: 0, to: der.count, by: 48)
|
||||
.map { offset -> String in
|
||||
let end = min(offset + 48, der.count)
|
||||
return der.subdata(in: offset..<end).base64EncodedString()
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
return "-----BEGIN \(label)-----\n\(chunks)\n-----END \(label)-----\n"
|
||||
}
|
||||
|
||||
private static func deviceId(publicKeyData: Data) -> String {
|
||||
SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func save(_ identity: DeviceIdentity, to url: URL) {
|
||||
@discardableResult
|
||||
private static func save(_ identity: DeviceIdentity) -> Bool {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(identity)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
let stored = self.storedIdentity(from: identity)
|
||||
try self.withSQLiteRetry {
|
||||
try OpenClawSQLiteStateStore.writeDeviceIdentity(
|
||||
key: self.identityKey,
|
||||
identity: OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId: stored.deviceId,
|
||||
publicKeyPem: stored.publicKeyPem,
|
||||
privateKeyPem: stored.privateKeyPem,
|
||||
createdAtMs: stored.createdAtMs))
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
// best-effort only
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func fileURL() -> URL {
|
||||
let base = DeviceIdentityPaths.stateDirURL()
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
private static func storedIdentity(from identity: DeviceIdentity) -> StoredDeviceIdentity {
|
||||
guard let publicKeyData = Data(base64Encoded: identity.publicKey),
|
||||
let privateKeyData = Data(base64Encoded: identity.privateKey)
|
||||
else {
|
||||
preconditionFailure("Generated OpenClaw device identity contains invalid base64")
|
||||
}
|
||||
return StoredDeviceIdentity(
|
||||
version: 1,
|
||||
deviceId: self.deviceId(publicKeyData: publicKeyData),
|
||||
publicKeyPem: self.pem(label: "PUBLIC KEY", der: self.ed25519SPKIPrefix + publicKeyData),
|
||||
privateKeyPem: self.pem(label: "PRIVATE KEY", der: self.ed25519PKCS8PrivatePrefix + privateKeyData),
|
||||
createdAtMs: identity.createdAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PemDeviceIdentity: Codable {
|
||||
private struct StoredDeviceIdentity: Codable {
|
||||
var version: Int
|
||||
var deviceId: String
|
||||
var publicKeyPem: String
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Talk
|
||||
@_exported import ElevenLabsKit
|
||||
|
||||
public typealias ElevenLabsVoice = ElevenLabsKit.ElevenLabsVoice
|
||||
@@ -7,3 +8,4 @@ public typealias TalkTTSValidation = ElevenLabsKit.TalkTTSValidation
|
||||
public typealias StreamingAudioPlayer = ElevenLabsKit.StreamingAudioPlayer
|
||||
public typealias PCMStreamingAudioPlayer = ElevenLabsKit.PCMStreamingAudioPlayer
|
||||
public typealias StreamingPlaybackResult = ElevenLabsKit.StreamingPlaybackResult
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SQLite3
|
||||
|
||||
public struct OpenClawSQLiteDeviceIdentityRow: Sendable {
|
||||
public let deviceId: String
|
||||
public let publicKeyPem: String
|
||||
public let privateKeyPem: String
|
||||
public let createdAtMs: Int
|
||||
|
||||
public init(deviceId: String, publicKeyPem: String, privateKeyPem: String, createdAtMs: Int) {
|
||||
self.deviceId = deviceId
|
||||
self.publicKeyPem = publicKeyPem
|
||||
self.privateKeyPem = privateKeyPem
|
||||
self.createdAtMs = createdAtMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawSQLiteDeviceAuthTokenRow: Sendable {
|
||||
public let deviceId: String
|
||||
public let role: String
|
||||
public let token: String
|
||||
public let scopesJSON: String
|
||||
public let updatedAtMs: Int
|
||||
|
||||
public init(deviceId: String, role: String, token: String, scopesJSON: String, updatedAtMs: Int) {
|
||||
self.deviceId = deviceId
|
||||
self.role = role
|
||||
self.token = token
|
||||
self.scopesJSON = scopesJSON
|
||||
self.updatedAtMs = updatedAtMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawSQLitePortGuardianRecord: Sendable {
|
||||
public let port: Int
|
||||
public let pid: Int32
|
||||
public let command: String
|
||||
public let mode: String
|
||||
public let timestamp: TimeInterval
|
||||
|
||||
public init(port: Int, pid: Int32, command: String, mode: String, timestamp: TimeInterval) {
|
||||
self.port = port
|
||||
self.pid = pid
|
||||
self.command = command
|
||||
self.mode = mode
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenClawSQLiteStateStore {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "sqlite-state")
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
|
||||
public static func databaseURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("state", isDirectory: true)
|
||||
.appendingPathComponent("openclaw.sqlite")
|
||||
}
|
||||
|
||||
public static func tableLocationForDisplay(table: String, key: String) -> String {
|
||||
"\(self.databaseURL().path)#table/\(table)/\(key)"
|
||||
}
|
||||
|
||||
public static func readDeviceIdentity(key: String = "default") -> OpenClawSQLiteDeviceIdentityRow? {
|
||||
do {
|
||||
return try self.readDeviceIdentityChecked(key: key)
|
||||
} catch {
|
||||
self.logger.warning("SQLite device identity read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func readDeviceIdentityChecked(key: String = "default") throws -> OpenClawSQLiteDeviceIdentityRow? {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
|
||||
let sql = """
|
||||
SELECT device_id, public_key_pem, private_key_pem, created_at_ms
|
||||
FROM device_identities
|
||||
WHERE identity_key = ?
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: key)
|
||||
|
||||
let status = sqlite3_step(statement)
|
||||
if status == SQLITE_ROW,
|
||||
let deviceId = self.columnString(statement, index: 0),
|
||||
let publicKeyPem = self.columnString(statement, index: 1),
|
||||
let privateKeyPem = self.columnString(statement, index: 2)
|
||||
{
|
||||
return OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId: deviceId,
|
||||
publicKeyPem: publicKeyPem,
|
||||
privateKeyPem: privateKeyPem,
|
||||
createdAtMs: Int(sqlite3_column_int64(statement, 3)))
|
||||
}
|
||||
if status == SQLITE_DONE { return nil }
|
||||
throw self.sqliteError(db, context: "SQLite device identity read failed")
|
||||
}
|
||||
|
||||
public static func writeDeviceIdentity(
|
||||
key: String = "default",
|
||||
identity: OpenClawSQLiteDeviceIdentityRow,
|
||||
updatedAtMs: Int = Int(Date().timeIntervalSince1970 * 1000)) throws
|
||||
{
|
||||
try self.withWriteTransaction { db in
|
||||
let sql = """
|
||||
INSERT INTO device_identities (
|
||||
identity_key, device_id, public_key_pem, private_key_pem, created_at_ms, updated_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(identity_key) DO UPDATE SET
|
||||
device_id = excluded.device_id,
|
||||
public_key_pem = excluded.public_key_pem,
|
||||
private_key_pem = excluded.private_key_pem,
|
||||
created_at_ms = excluded.created_at_ms,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: key)
|
||||
self.bindText(statement, index: 2, value: identity.deviceId)
|
||||
self.bindText(statement, index: 3, value: identity.publicKeyPem)
|
||||
self.bindText(statement, index: 4, value: identity.privateKeyPem)
|
||||
sqlite3_bind_int64(statement, 5, Int64(identity.createdAtMs))
|
||||
sqlite3_bind_int64(statement, 6, Int64(updatedAtMs))
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw self.sqliteError(db, context: "SQLite device identity write failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func readDeviceAuthToken(deviceId: String, role: String) -> OpenClawSQLiteDeviceAuthTokenRow? {
|
||||
do {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
|
||||
let sql = """
|
||||
SELECT device_id, role, token, scopes_json, updated_at_ms
|
||||
FROM device_auth_tokens
|
||||
WHERE device_id = ? AND role = ?
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: deviceId)
|
||||
self.bindText(statement, index: 2, value: role)
|
||||
let status = sqlite3_step(statement)
|
||||
if status == SQLITE_ROW,
|
||||
let rowDeviceId = self.columnString(statement, index: 0),
|
||||
let rowRole = self.columnString(statement, index: 1),
|
||||
let token = self.columnString(statement, index: 2),
|
||||
let scopesJSON = self.columnString(statement, index: 3)
|
||||
{
|
||||
return OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId: rowDeviceId,
|
||||
role: rowRole,
|
||||
token: token,
|
||||
scopesJSON: scopesJSON,
|
||||
updatedAtMs: Int(sqlite3_column_int64(statement, 4)))
|
||||
}
|
||||
if status == SQLITE_DONE { return nil }
|
||||
throw self.sqliteError(db, context: "SQLite device auth read failed")
|
||||
} catch {
|
||||
self.logger.warning("SQLite device auth read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func readLatestDeviceAuthDeviceId() -> String? {
|
||||
do {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
let sql = """
|
||||
SELECT device_id
|
||||
FROM device_auth_tokens
|
||||
ORDER BY updated_at_ms DESC, device_id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
let status = sqlite3_step(statement)
|
||||
if status == SQLITE_ROW { return self.columnString(statement, index: 0) }
|
||||
if status == SQLITE_DONE { return nil }
|
||||
throw self.sqliteError(db, context: "SQLite device auth latest-device read failed")
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"SQLite device auth latest-device read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func upsertDeviceAuthToken(_ row: OpenClawSQLiteDeviceAuthTokenRow) throws {
|
||||
try self.withWriteTransaction { db in
|
||||
let sql = """
|
||||
INSERT INTO device_auth_tokens (device_id, role, token, scopes_json, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_id, role) DO UPDATE SET
|
||||
token = excluded.token,
|
||||
scopes_json = excluded.scopes_json,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: row.deviceId)
|
||||
self.bindText(statement, index: 2, value: row.role)
|
||||
self.bindText(statement, index: 3, value: row.token)
|
||||
self.bindText(statement, index: 4, value: row.scopesJSON)
|
||||
sqlite3_bind_int64(statement, 5, Int64(row.updatedAtMs))
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw self.sqliteError(db, context: "SQLite device auth write failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func deleteDeviceAuthToken(deviceId: String, role: String) throws {
|
||||
try self.withWriteTransaction { db in
|
||||
let sql = "DELETE FROM device_auth_tokens WHERE device_id = ? AND role = ?"
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: deviceId)
|
||||
self.bindText(statement, index: 2, value: role)
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw self.sqliteError(db, context: "SQLite device auth delete failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func deleteAllDeviceAuthTokens() throws {
|
||||
try self.withWriteTransaction { db in
|
||||
try self.exec(db, "DELETE FROM device_auth_tokens")
|
||||
}
|
||||
}
|
||||
|
||||
public static func execApprovalsLocationForDisplay(configKey: String = "current") -> String {
|
||||
self.tableLocationForDisplay(table: "exec_approvals_config", key: configKey)
|
||||
}
|
||||
|
||||
public static func readExecApprovalsRaw(configKey: String = "current") -> String? {
|
||||
do {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
let sql = "SELECT raw_json FROM exec_approvals_config WHERE config_key = ?"
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: configKey)
|
||||
let status = sqlite3_step(statement)
|
||||
if status == SQLITE_ROW { return self.columnString(statement, index: 0) }
|
||||
if status == SQLITE_DONE { return nil }
|
||||
throw self.sqliteError(db, context: "SQLite exec approvals read failed")
|
||||
} catch {
|
||||
self.logger.warning("SQLite exec approvals read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func writeExecApprovalsConfig(
|
||||
configKey: String = "current",
|
||||
rawJSON: String,
|
||||
socketPath: String?,
|
||||
hasSocketToken: Bool,
|
||||
defaultSecurity: String?,
|
||||
defaultAsk: String?,
|
||||
defaultAskFallback: String?,
|
||||
autoAllowSkills: Bool?,
|
||||
agentCount: Int,
|
||||
allowlistCount: Int,
|
||||
updatedAtMs: Int = Int(Date().timeIntervalSince1970 * 1000)) throws
|
||||
{
|
||||
try self.withWriteTransaction { db in
|
||||
let sql = """
|
||||
INSERT INTO exec_approvals_config (
|
||||
config_key, raw_json, socket_path, has_socket_token, default_security,
|
||||
default_ask, default_ask_fallback, auto_allow_skills,
|
||||
agent_count, allowlist_count, updated_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(config_key) DO UPDATE SET
|
||||
raw_json = excluded.raw_json,
|
||||
socket_path = excluded.socket_path,
|
||||
has_socket_token = excluded.has_socket_token,
|
||||
default_security = excluded.default_security,
|
||||
default_ask = excluded.default_ask,
|
||||
default_ask_fallback = excluded.default_ask_fallback,
|
||||
auto_allow_skills = excluded.auto_allow_skills,
|
||||
agent_count = excluded.agent_count,
|
||||
allowlist_count = excluded.allowlist_count,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: configKey)
|
||||
self.bindText(statement, index: 2, value: rawJSON)
|
||||
self.bindNullableText(statement, index: 3, value: socketPath)
|
||||
sqlite3_bind_int(statement, 4, hasSocketToken ? 1 : 0)
|
||||
self.bindNullableText(statement, index: 5, value: defaultSecurity)
|
||||
self.bindNullableText(statement, index: 6, value: defaultAsk)
|
||||
self.bindNullableText(statement, index: 7, value: defaultAskFallback)
|
||||
if let autoAllowSkills {
|
||||
sqlite3_bind_int(statement, 8, autoAllowSkills ? 1 : 0)
|
||||
} else {
|
||||
sqlite3_bind_null(statement, 8)
|
||||
}
|
||||
sqlite3_bind_int(statement, 9, Int32(agentCount))
|
||||
sqlite3_bind_int(statement, 10, Int32(allowlistCount))
|
||||
sqlite3_bind_int64(statement, 11, Int64(updatedAtMs))
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw self.sqliteError(db, context: "SQLite exec approvals write failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func readConfigHealthState() -> [String: Any] {
|
||||
do {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
let sql = """
|
||||
SELECT config_path, last_known_good_json, last_promoted_good_json, last_observed_suspicious_signature
|
||||
FROM config_health_entries
|
||||
ORDER BY config_path ASC
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var entries: [String: Any] = [:]
|
||||
while true {
|
||||
let status = sqlite3_step(statement)
|
||||
if status == SQLITE_DONE { break }
|
||||
guard status == SQLITE_ROW, let configPath = self.columnString(statement, index: 0) else {
|
||||
throw self.sqliteError(db, context: "SQLite config health read failed")
|
||||
}
|
||||
var entry: [String: Any] = [:]
|
||||
if let lastKnownGood = self.columnJSONDictionary(statement, index: 1) {
|
||||
entry["lastKnownGood"] = lastKnownGood
|
||||
}
|
||||
if let lastPromotedGood = self.columnJSONDictionary(statement, index: 2) {
|
||||
entry["lastPromotedGood"] = lastPromotedGood
|
||||
}
|
||||
if let signature = self.columnString(statement, index: 3) {
|
||||
entry["lastObservedSuspiciousSignature"] = signature
|
||||
}
|
||||
entries[configPath] = entry
|
||||
}
|
||||
return entries.isEmpty ? [:] : ["entries": entries]
|
||||
} catch {
|
||||
self.logger.warning("SQLite config health read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
public static func writeConfigHealthState(_ state: [String: Any]) throws {
|
||||
let entries = state["entries"] as? [String: Any] ?? [:]
|
||||
let updatedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
try self.withWriteTransaction { db in
|
||||
try self.exec(db, "DELETE FROM config_health_entries")
|
||||
for (configPath, rawEntry) in entries {
|
||||
guard let entry = rawEntry as? [String: Any] else { continue }
|
||||
try self.insertConfigHealthEntry(
|
||||
db,
|
||||
configPath: configPath,
|
||||
entry: entry,
|
||||
updatedAtMs: updatedAtMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func readPortGuardianRecords() -> [OpenClawSQLitePortGuardianRecord] {
|
||||
do {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
let sql = """
|
||||
SELECT port, pid, command, mode, timestamp
|
||||
FROM macos_port_guardian_records
|
||||
ORDER BY timestamp ASC, pid ASC
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var rows: [OpenClawSQLitePortGuardianRecord] = []
|
||||
while true {
|
||||
let status = sqlite3_step(statement)
|
||||
if status == SQLITE_DONE { break }
|
||||
guard status == SQLITE_ROW else {
|
||||
throw self.sqliteError(db, context: "SQLite port guardian read failed")
|
||||
}
|
||||
guard let command = self.columnString(statement, index: 2),
|
||||
let mode = self.columnString(statement, index: 3)
|
||||
else { continue }
|
||||
rows.append(OpenClawSQLitePortGuardianRecord(
|
||||
port: Int(sqlite3_column_int(statement, 0)),
|
||||
pid: sqlite3_column_int(statement, 1),
|
||||
command: command,
|
||||
mode: mode,
|
||||
timestamp: sqlite3_column_double(statement, 4)))
|
||||
}
|
||||
return rows
|
||||
} catch {
|
||||
self.logger.warning("SQLite port guardian read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public static func replacePortGuardianRecords(_ records: [OpenClawSQLitePortGuardianRecord]) throws {
|
||||
try self.withWriteTransaction { db in
|
||||
try self.exec(db, "DELETE FROM macos_port_guardian_records")
|
||||
for record in records {
|
||||
try self.insertPortGuardianRecord(db, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func openStateDatabase() throws -> OpaquePointer? {
|
||||
self.ensureSecureStateDirectory()
|
||||
let url = self.databaseURL()
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try? FileManager().setAttributes(
|
||||
[.posixPermissions: self.secureStateDirPermissions],
|
||||
ofItemAtPath: url.deletingLastPathComponent().path)
|
||||
|
||||
var db: OpaquePointer?
|
||||
guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) == SQLITE_OK
|
||||
else {
|
||||
defer { sqlite3_close(db) }
|
||||
throw self.sqliteError(db, context: "SQLite state open failed")
|
||||
}
|
||||
sqlite3_busy_timeout(db, 30_000)
|
||||
try self.configureStateDatabase(db)
|
||||
self.hardenStateDatabaseFiles()
|
||||
return db
|
||||
}
|
||||
|
||||
private static func configureStateDatabase(_ db: OpaquePointer?) throws {
|
||||
try self.exec(db, "PRAGMA journal_mode = WAL")
|
||||
try self.exec(db, "PRAGMA synchronous = NORMAL")
|
||||
try self.exec(db, "PRAGMA busy_timeout = 30000")
|
||||
try self.exec(db, "PRAGMA foreign_keys = ON")
|
||||
try self.exec(
|
||||
db,
|
||||
"""
|
||||
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
|
||||
)
|
||||
""")
|
||||
try self.exec(
|
||||
db,
|
||||
"CREATE INDEX IF NOT EXISTS idx_device_identities_device ON device_identities(device_id, updated_at_ms DESC)")
|
||||
try self.exec(
|
||||
db,
|
||||
"""
|
||||
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)
|
||||
)
|
||||
""")
|
||||
try self.exec(
|
||||
db,
|
||||
"CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated ON device_auth_tokens(updated_at_ms DESC, device_id, role)")
|
||||
try self.exec(
|
||||
db,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS exec_approvals_config (
|
||||
config_key TEXT NOT NULL PRIMARY KEY,
|
||||
raw_json TEXT NOT NULL,
|
||||
socket_path TEXT,
|
||||
has_socket_token INTEGER NOT NULL,
|
||||
default_security TEXT,
|
||||
default_ask TEXT,
|
||||
default_ask_fallback TEXT,
|
||||
auto_allow_skills INTEGER,
|
||||
agent_count INTEGER NOT NULL,
|
||||
allowlist_count INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
try self.exec(
|
||||
db,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS macos_port_guardian_records (
|
||||
pid INTEGER NOT NULL PRIMARY KEY,
|
||||
port INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
timestamp REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
try self.exec(
|
||||
db,
|
||||
"CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port ON macos_port_guardian_records(port, timestamp DESC)")
|
||||
try self.exec(
|
||||
db,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS config_health_entries (
|
||||
config_path TEXT NOT NULL PRIMARY KEY,
|
||||
last_known_good_json TEXT,
|
||||
last_promoted_good_json TEXT,
|
||||
last_observed_suspicious_signature TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
}
|
||||
|
||||
private static func prepare(_ db: OpaquePointer?, _ sql: String, _ statement: inout OpaquePointer?) throws {
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
throw self.sqliteError(db, context: "SQLite state prepare failed")
|
||||
}
|
||||
}
|
||||
|
||||
private static func insertPortGuardianRecord(
|
||||
_ db: OpaquePointer?,
|
||||
_ record: OpenClawSQLitePortGuardianRecord) throws
|
||||
{
|
||||
let sql = """
|
||||
INSERT INTO macos_port_guardian_records (pid, port, command, mode, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_int(statement, 1, record.pid)
|
||||
sqlite3_bind_int(statement, 2, Int32(record.port))
|
||||
self.bindText(statement, index: 3, value: record.command)
|
||||
self.bindText(statement, index: 4, value: record.mode)
|
||||
sqlite3_bind_double(statement, 5, record.timestamp)
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw self.sqliteError(db, context: "SQLite port guardian write failed")
|
||||
}
|
||||
}
|
||||
|
||||
private static func exec(_ db: OpaquePointer?, _ sql: String) throws {
|
||||
var errorMessage: UnsafeMutablePointer<CChar>?
|
||||
if sqlite3_exec(db, sql, nil, nil, &errorMessage) != SQLITE_OK {
|
||||
let message = errorMessage.map { String(cString: $0) }
|
||||
sqlite3_free(errorMessage)
|
||||
throw NSError(
|
||||
domain: "OpenClawSQLiteStateStore",
|
||||
code: Int(sqlite3_errcode(db)),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: message ?? sqlite3ErrorMessage(db),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private static func bindText(_ statement: OpaquePointer?, index: Int32, value: String) {
|
||||
let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
sqlite3_bind_text(statement, index, value, -1, transient)
|
||||
}
|
||||
|
||||
private static func bindNullableText(_ statement: OpaquePointer?, index: Int32, value: String?) {
|
||||
guard let value else {
|
||||
sqlite3_bind_null(statement, index)
|
||||
return
|
||||
}
|
||||
self.bindText(statement, index: index, value: value)
|
||||
}
|
||||
|
||||
private static func columnString(_ statement: OpaquePointer?, index: Int32) -> String? {
|
||||
guard let raw = sqlite3_column_text(statement, index) else { return nil }
|
||||
return String(cString: UnsafeRawPointer(raw).assumingMemoryBound(to: CChar.self))
|
||||
}
|
||||
|
||||
private static func columnJSONDictionary(_ statement: OpaquePointer?, index: Int32) -> [String: Any]? {
|
||||
guard let raw = self.columnString(statement, index: index),
|
||||
let data = raw.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func jsonString(_ value: Any?) -> String? {
|
||||
guard let value, !(value is NSNull), JSONSerialization.isValidJSONObject(value),
|
||||
let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
||||
else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func insertConfigHealthEntry(
|
||||
_ db: OpaquePointer?,
|
||||
configPath: String,
|
||||
entry: [String: Any],
|
||||
updatedAtMs: Int) throws
|
||||
{
|
||||
let sql = """
|
||||
INSERT INTO config_health_entries (
|
||||
config_path, last_known_good_json, last_promoted_good_json,
|
||||
last_observed_suspicious_signature, updated_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
"""
|
||||
var statement: OpaquePointer?
|
||||
try self.prepare(db, sql, &statement)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
self.bindText(statement, index: 1, value: configPath)
|
||||
self.bindNullableText(statement, index: 2, value: self.jsonString(entry["lastKnownGood"]))
|
||||
self.bindNullableText(statement, index: 3, value: self.jsonString(entry["lastPromotedGood"]))
|
||||
self.bindNullableText(
|
||||
statement,
|
||||
index: 4,
|
||||
value: entry["lastObservedSuspiciousSignature"] as? String)
|
||||
sqlite3_bind_int64(statement, 5, Int64(updatedAtMs))
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw self.sqliteError(db, context: "SQLite config health write failed")
|
||||
}
|
||||
}
|
||||
|
||||
private static func withWriteTransaction(_ body: (OpaquePointer?) throws -> Void) throws {
|
||||
let db = try self.openStateDatabase()
|
||||
defer { sqlite3_close(db) }
|
||||
|
||||
try self.exec(db, "BEGIN IMMEDIATE")
|
||||
do {
|
||||
try body(db)
|
||||
try self.exec(db, "COMMIT")
|
||||
} catch {
|
||||
try? self.exec(db, "ROLLBACK")
|
||||
throw error
|
||||
}
|
||||
self.hardenStateDatabaseFiles()
|
||||
}
|
||||
|
||||
private static func sqliteError(_ db: OpaquePointer?, context: String) -> NSError {
|
||||
NSError(
|
||||
domain: "OpenClawSQLiteStateStore",
|
||||
code: Int(sqlite3_errcode(db)),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(context): \(self.sqlite3ErrorMessage(db))",
|
||||
])
|
||||
}
|
||||
|
||||
private static func sqlite3ErrorMessage(_ db: OpaquePointer?) -> String {
|
||||
guard let message = sqlite3_errmsg(db) else {
|
||||
return "unknown SQLite error"
|
||||
}
|
||||
return String(cString: message)
|
||||
}
|
||||
|
||||
private static func hardenStateDatabaseFiles() {
|
||||
let path = self.databaseURL().path
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let candidate = "\(path)\(suffix)"
|
||||
if FileManager().fileExists(atPath: candidate) {
|
||||
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureSecureStateDirectory() {
|
||||
let url = DeviceIdentityPaths.stateDirURL()
|
||||
do {
|
||||
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.secureStateDirPermissions],
|
||||
ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"SQLite state dir permission hardening failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -759,6 +759,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let sessioneffects: AnyCodable?
|
||||
public let sourcereplydeliverymode: AnyCodable?
|
||||
public let disablemessagetool: Bool?
|
||||
public let initialvfsentries: [[String: AnyCodable]]?
|
||||
public let voicewaketrigger: String?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
@@ -800,6 +801,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
sessioneffects: AnyCodable?,
|
||||
sourcereplydeliverymode: AnyCodable?,
|
||||
disablemessagetool: Bool?,
|
||||
initialvfsentries: [[String: AnyCodable]]?,
|
||||
voicewaketrigger: String?,
|
||||
idempotencykey: String,
|
||||
label: String?)
|
||||
@@ -840,6 +842,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.sessioneffects = sessioneffects
|
||||
self.sourcereplydeliverymode = sourcereplydeliverymode
|
||||
self.disablemessagetool = disablemessagetool
|
||||
self.initialvfsentries = initialvfsentries
|
||||
self.voicewaketrigger = voicewaketrigger
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
@@ -882,6 +885,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case sessioneffects = "sessionEffects"
|
||||
case sourcereplydeliverymode = "sourceReplyDeliveryMode"
|
||||
case disablemessagetool = "disableMessageTool"
|
||||
case initialvfsentries = "initialVfsEntries"
|
||||
case voicewaketrigger = "voiceWakeTrigger"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
@@ -1598,12 +1602,12 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let configuredagentsonly: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
public let configuredagentsonly: Bool?
|
||||
public let search: String?
|
||||
|
||||
public init(
|
||||
@@ -1612,12 +1616,12 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
configuredagentsonly: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String? = nil,
|
||||
configuredagentsonly: Bool?,
|
||||
search: String?)
|
||||
{
|
||||
self.limit = limit
|
||||
@@ -1625,12 +1629,12 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.configuredagentsonly = configuredagentsonly
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
self.configuredagentsonly = configuredagentsonly
|
||||
self.search = search
|
||||
}
|
||||
|
||||
@@ -1640,50 +1644,16 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case configuredagentsonly = "configuredAgentsOnly"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
case configuredagentsonly = "configuredAgentsOnly"
|
||||
case search
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCleanupParams: Codable, Sendable {
|
||||
public let agent: String?
|
||||
public let allagents: Bool?
|
||||
public let enforce: Bool?
|
||||
public let activekey: String?
|
||||
public let fixmissing: Bool?
|
||||
public let fixdmscope: Bool?
|
||||
|
||||
public init(
|
||||
agent: String?,
|
||||
allagents: Bool?,
|
||||
enforce: Bool?,
|
||||
activekey: String?,
|
||||
fixmissing: Bool?,
|
||||
fixdmscope: Bool?)
|
||||
{
|
||||
self.agent = agent
|
||||
self.allagents = allagents
|
||||
self.enforce = enforce
|
||||
self.activekey = activekey
|
||||
self.fixmissing = fixmissing
|
||||
self.fixdmscope = fixdmscope
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agent
|
||||
case allagents = "allAgents"
|
||||
case enforce
|
||||
case activekey = "activeKey"
|
||||
case fixmissing = "fixMissing"
|
||||
case fixdmscope = "fixDmScope"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPreviewParams: Codable, Sendable {
|
||||
public let keys: [String]
|
||||
public let limit: Int?
|
||||
@@ -6880,6 +6850,54 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let messageid: String
|
||||
public let maxchars: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
messageid: String,
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.messageid = messageid
|
||||
self.maxchars = maxchars
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case messageid = "messageId"
|
||||
case maxchars = "maxChars"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let message: AnyCodable?
|
||||
public let unavailablereason: AnyCodable?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
message: AnyCodable?,
|
||||
unavailablereason: AnyCodable?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.message = message
|
||||
self.unavailablereason = unavailablereason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case message
|
||||
case unavailablereason = "unavailableReason"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
@@ -396,7 +396,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
}
|
||||
return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse(
|
||||
ts: nil,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 0,
|
||||
defaults: nil,
|
||||
sessions: [])
|
||||
@@ -1226,7 +1226,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 4,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1250,7 +1250,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1275,7 +1275,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload(sessionKey: "Luke’s MacBook Pro", sessionId: "sess-main")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
model: nil,
|
||||
@@ -1323,7 +1323,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
model: nil,
|
||||
@@ -1656,7 +1656,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
|
||||
sessions: [
|
||||
@@ -1684,7 +1684,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
|
||||
sessions: [
|
||||
@@ -1717,7 +1717,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
|
||||
sessions: [
|
||||
@@ -1750,7 +1750,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1783,7 +1783,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1826,7 +1826,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1879,7 +1879,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1929,7 +1929,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1977,7 +1977,7 @@ extension TestChatTransportState {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 2,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -2022,7 +2022,7 @@ extension TestChatTransportState {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let initialSessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 2,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -2031,7 +2031,7 @@ extension TestChatTransportState {
|
||||
])
|
||||
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 2,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -2189,10 +2189,10 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "adaptive")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "openai-codex",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: nil,
|
||||
thinkingLevels: [
|
||||
@@ -2252,7 +2252,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "xhigh")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -2300,7 +2300,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "adaptive")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "anthropic",
|
||||
@@ -2356,7 +2356,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "max")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
path: nil,
|
||||
databasePath: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "anthropic",
|
||||
|
||||
@@ -5,68 +5,419 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceIdentityStoreTests {
|
||||
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
|
||||
func loadsTypeScriptPEMIdentitySchema() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let identityURL = tempDir
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device.json", isDirectory: false)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: identityURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let stored = try Self.identityJSON(
|
||||
publicKeyPem: Self.pem(
|
||||
label: "PUBLIC KEY",
|
||||
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
|
||||
privateKeyPem: Self.pem(
|
||||
label: "PRIVATE KEY",
|
||||
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
|
||||
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let before = try String(contentsOf: identityURL, encoding: .utf8)
|
||||
@Test("persists generated device identity in SQLite without JSON sidecars")
|
||||
func persistsGeneratedIdentityInSQLite() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let loaded = DeviceIdentityStore.loadOrCreate()
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||
#expect(loaded.deviceId == identity.deviceId)
|
||||
#expect(loaded.publicKey == identity.publicKey)
|
||||
#expect(FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
|
||||
#expect(!FileManager.default.fileExists(atPath: Self.legacyIdentityURL(stateDir: stateDir).path))
|
||||
|
||||
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
|
||||
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
|
||||
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
|
||||
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
|
||||
let signatureData = try #require(Self.base64UrlDecode(signature))
|
||||
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
|
||||
#expect(publicKey.isValidSignature(signatureData, for: Data("hello".utf8)))
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
let stored = try #require(OpenClawSQLiteStateStore.readDeviceIdentity())
|
||||
#expect(stored.deviceId == identity.deviceId)
|
||||
#expect(stored.publicKeyPem.contains("BEGIN PUBLIC KEY"))
|
||||
#expect(stored.privateKeyPem.contains(Self.privateKeyMarker("BEGIN")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("does not overwrite a recognized invalid TypeScript identity schema")
|
||||
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
|
||||
@Test("surfaces SQLite identity read failures distinctly from missing rows")
|
||||
func surfacesSQLiteIdentityReadFailures() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
try FileManager.default.createDirectory(
|
||||
at: Self.databaseURL(stateDir: stateDir),
|
||||
withIntermediateDirectories: true)
|
||||
|
||||
#expect(throws: Error.self) {
|
||||
_ = try OpenClawSQLiteStateStore.readDeviceIdentityChecked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("loads TypeScript PEM identity schema from SQLite")
|
||||
func loadsTypeScriptPEMIdentitySchema() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let stored = try Self.identityJSON(
|
||||
publicKeyPem: Self.pem(
|
||||
label: "PUBLIC KEY",
|
||||
body: "MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg="),
|
||||
privateKeyPem: Self.pem(
|
||||
label: "PRIVATE" + " KEY",
|
||||
body: "MC4CAQAwBQYDK2VwBCIEIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"))
|
||||
let object = try #require(try JSONSerialization.jsonObject(with: stored) as? [String: Any])
|
||||
try OpenClawSQLiteStateStore.writeDeviceIdentity(
|
||||
identity: OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId: try #require(object["deviceId"] as? String),
|
||||
publicKeyPem: try #require(object["publicKeyPem"] as? String),
|
||||
privateKeyPem: try #require(object["privateKeyPem"] as? String),
|
||||
createdAtMs: try #require(object["createdAtMs"] as? Int)))
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
|
||||
#expect(identity.deviceId == "56475aa75463474c0285df5dbf2bcab73da651358839e9b77481b2eab107708c")
|
||||
#expect(identity.publicKey == "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||
#expect(identity.privateKey == "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||
#expect(DeviceIdentityStore.publicKeyBase64Url(identity) == "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg")
|
||||
#expect(!FileManager.default.fileExists(atPath: Self.legacyIdentityURL(stateDir: stateDir).path))
|
||||
|
||||
let signature = try #require(DeviceIdentityStore.signPayload("hello", identity: identity))
|
||||
let publicKeyData = try #require(Data(base64Encoded: identity.publicKey))
|
||||
let signatureData = try #require(Self.base64UrlDecode(signature))
|
||||
let publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData)
|
||||
#expect(publicKey.isValidSignature(signatureData, for: Data("hello".utf8)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("migrates legacy raw device identity sidecar into SQLite")
|
||||
func migratesLegacyRawIdentitySidecarIntoSQLite() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyIdentityURL(stateDir: stateDir)
|
||||
try FileManager.default.createDirectory(
|
||||
at: legacyURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let legacy = Self.legacyRawIdentity()
|
||||
let data = try JSONEncoder().encode(legacy)
|
||||
try data.write(to: legacyURL)
|
||||
|
||||
#expect(DeviceIdentityStore.legacyIdentityMigrationRequired())
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
|
||||
#expect(identity.deviceId == legacy.deviceId)
|
||||
#expect(identity.publicKey == legacy.publicKey)
|
||||
#expect(identity.privateKey == legacy.privateKey)
|
||||
#expect(identity.createdAtMs == legacy.createdAtMs)
|
||||
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
|
||||
let stored = try #require(OpenClawSQLiteStateStore.readDeviceIdentity())
|
||||
#expect(stored.deviceId == legacy.deviceId)
|
||||
#expect(FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("keeps default legacy device state under app support OpenClaw dir")
|
||||
func keepsDefaultLegacyDeviceStateUnderAppSupportOpenClawDir() throws {
|
||||
try Self.withDefaultStateEnvironment {
|
||||
let appSupport = try #require(
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first)
|
||||
let expected = appSupport
|
||||
.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
.standardizedFileURL
|
||||
|
||||
#expect(DeviceIdentityPaths.legacyStateDirURL().standardizedFileURL == expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("stores device auth tokens in SQLite without JSON sidecars")
|
||||
func storesDeviceAuthTokensInSQLite() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let entry = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-1",
|
||||
role: " gateway ",
|
||||
token: "token-1",
|
||||
scopes: ["write", " read ", "write"])
|
||||
|
||||
#expect(entry.role == "gateway")
|
||||
#expect(entry.scopes == ["read", "write"])
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway")?.token == "token-1")
|
||||
#expect(!FileManager.default.fileExists(atPath: Self.legacyAuthURL(stateDir: stateDir).path))
|
||||
|
||||
let stored = try #require(OpenClawSQLiteStateStore.readDeviceAuthToken(
|
||||
deviceId: "device-1",
|
||||
role: "gateway"))
|
||||
#expect(stored.token == "token-1")
|
||||
#expect(stored.scopesJSON.contains("read"))
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: "device-1", role: "gateway")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("migrates legacy device auth sidecar into SQLite")
|
||||
func migratesLegacyDeviceAuthSidecarIntoSQLite() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
|
||||
try Self.writeLegacyAuthSidecar(
|
||||
legacyURL,
|
||||
deviceId: "device-1",
|
||||
token: "token-1",
|
||||
scopes: ["write", " read ", "write"])
|
||||
|
||||
let entry = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
|
||||
|
||||
#expect(entry.token == "token-1")
|
||||
#expect(entry.role == "gateway")
|
||||
#expect(entry.scopes == ["read", "write"])
|
||||
#expect(entry.updatedAtMs == 1_700_000_000_000)
|
||||
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
let stored = try #require(OpenClawSQLiteStateStore.readDeviceAuthToken(
|
||||
deviceId: "device-1",
|
||||
role: "gateway"))
|
||||
#expect(stored.token == "token-1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ignores Android SecurePrefs token markers in shared SQLite auth rows")
|
||||
func ignoresAndroidSecurePrefsTokenMarkers() throws {
|
||||
try Self.withTempStateDir { _ in
|
||||
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId: "device-1",
|
||||
role: "gateway",
|
||||
token: "__openclaw_secure_prefs__",
|
||||
scopesJSON: "[]",
|
||||
updatedAtMs: 1))
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("migrates same-device legacy auth roles before the first SQLite save")
|
||||
func migratesSameDeviceLegacyAuthRolesBeforeFirstSQLiteSave() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
|
||||
try Self.writeLegacyAuthSidecar(
|
||||
legacyURL,
|
||||
deviceId: "device-1",
|
||||
tokens: [
|
||||
"gateway": ("gateway-token", ["read"]),
|
||||
"operator": ("old-operator-token", ["operator.read"]),
|
||||
])
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "operator-token",
|
||||
scopes: ["operator.write"])
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway")?.token == "gateway-token")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "operator")?.token == "operator-token")
|
||||
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("does not resurrect legacy device auth role after SQLite rows exist")
|
||||
func doesNotResurrectLegacyDeviceAuthRoleAfterSQLiteRowsExist() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "operator-token",
|
||||
scopes: ["operator"])
|
||||
|
||||
try Self.writeLegacyAuthSidecar(
|
||||
legacyURL,
|
||||
deviceId: "device-1",
|
||||
role: "admin",
|
||||
token: "stale-admin-token",
|
||||
scopes: ["admin"])
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "operator-token-2",
|
||||
scopes: ["operator"])
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "admin") == nil)
|
||||
#expect(OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: "device-1", role: "admin") == nil)
|
||||
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("keeps legacy device auth sidecar when SQLite import fails")
|
||||
func keepsLegacyDeviceAuthSidecarWhenSQLiteImportFails() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
|
||||
try Self.writeLegacyAuthSidecar(
|
||||
legacyURL,
|
||||
deviceId: "device-1",
|
||||
token: "token-1",
|
||||
scopes: ["read"])
|
||||
try FileManager.default.createDirectory(
|
||||
at: Self.databaseURL(stateDir: stateDir),
|
||||
withIntermediateDirectories: true)
|
||||
|
||||
let entry = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
|
||||
#expect(entry.token == "token-1")
|
||||
#expect(entry.scopes == ["read"])
|
||||
#expect(FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("falls back to legacy device auth sidecar when SQLite write fails")
|
||||
func fallsBackToLegacyDeviceAuthSidecarWhenSQLiteWriteFails() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
try FileManager.default.createDirectory(
|
||||
at: Self.databaseURL(stateDir: stateDir),
|
||||
withIntermediateDirectories: true)
|
||||
|
||||
let entry = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-1",
|
||||
role: " gateway ",
|
||||
token: "token-fallback",
|
||||
scopes: ["write"])
|
||||
|
||||
#expect(entry.token == "token-fallback")
|
||||
#expect(entry.role == "gateway")
|
||||
#expect(FileManager.default.fileExists(atPath: Self.legacyAuthURL(stateDir: stateDir).path))
|
||||
let loaded = try #require(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway"))
|
||||
#expect(loaded.token == "token-fallback")
|
||||
#expect(loaded.scopes == ["write"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("merges legacy device auth sidecar when SQLite write fails")
|
||||
func mergesLegacyDeviceAuthSidecarWhenSQLiteWriteFails() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
|
||||
try Self.writeLegacyAuthSidecar(
|
||||
legacyURL,
|
||||
deviceId: "device-1",
|
||||
token: "gateway-token",
|
||||
scopes: ["read"])
|
||||
try FileManager.default.createDirectory(
|
||||
at: Self.databaseURL(stateDir: stateDir),
|
||||
withIntermediateDirectories: true)
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-1",
|
||||
role: "operator",
|
||||
token: "operator-token",
|
||||
scopes: ["write"])
|
||||
|
||||
let data = try Data(contentsOf: legacyURL)
|
||||
let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let tokens = try #require(root["tokens"] as? [String: Any])
|
||||
#expect((tokens["gateway"] as? [String: Any])?["token"] as? String == "gateway-token")
|
||||
#expect((tokens["operator"] as? [String: Any])?["token"] as? String == "operator-token")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("drops stale legacy device auth sidecar when storing a different device")
|
||||
func dropsStaleLegacyDeviceAuthSidecarWhenReplacingDevice() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyAuthURL(stateDir: stateDir)
|
||||
try Self.writeLegacyAuthSidecar(
|
||||
legacyURL,
|
||||
deviceId: "device-1",
|
||||
token: "stale-token",
|
||||
scopes: ["read"])
|
||||
|
||||
let entry = DeviceAuthStore.storeToken(
|
||||
deviceId: "device-2",
|
||||
role: "gateway",
|
||||
token: "fresh-token",
|
||||
scopes: ["write"])
|
||||
|
||||
#expect(entry.token == "fresh-token")
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-1", role: "gateway") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: "device-2", role: "gateway")?.token == "fresh-token")
|
||||
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
private static func withTempStateDir(_ body: (URL) throws -> Void) throws {
|
||||
let previous = DeviceIdentityPaths.testingStateDirURL
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let identityURL = tempDir
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
DeviceIdentityPaths.testingStateDirURL = tempDir
|
||||
defer {
|
||||
DeviceIdentityPaths.testingStateDirURL = previous
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
try body(tempDir)
|
||||
}
|
||||
|
||||
private static func withDefaultStateEnvironment(_ body: () throws -> Void) throws {
|
||||
let previousTestingStateDir = DeviceIdentityPaths.testingStateDirURL
|
||||
let previousStateDir = getenv("OPENCLAW_STATE_DIR").map { String(cString: $0) }
|
||||
DeviceIdentityPaths.testingStateDirURL = nil
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
defer {
|
||||
DeviceIdentityPaths.testingStateDirURL = previousTestingStateDir
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
}
|
||||
try body()
|
||||
}
|
||||
|
||||
private static func databaseURL(stateDir: URL) -> URL {
|
||||
stateDir
|
||||
.appendingPathComponent("state", isDirectory: true)
|
||||
.appendingPathComponent("openclaw.sqlite")
|
||||
}
|
||||
|
||||
private static func legacyIdentityURL(stateDir: URL) -> URL {
|
||||
stateDir
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device.json", isDirectory: false)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
}
|
||||
|
||||
private static func legacyAuthURL(stateDir: URL) -> URL {
|
||||
stateDir
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device-auth.json", isDirectory: false)
|
||||
}
|
||||
|
||||
private static func writeLegacyAuthSidecar(
|
||||
_ legacyURL: URL,
|
||||
deviceId: String,
|
||||
role: String = "gateway",
|
||||
token: String,
|
||||
scopes: [String]) throws
|
||||
{
|
||||
try FileManager.default.createDirectory(
|
||||
at: identityURL.deletingLastPathComponent(),
|
||||
at: legacyURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let stored = """
|
||||
{
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
"publicKeyPem": "not-a-valid-public-key",
|
||||
"privateKeyPem": "not-a-valid-private-key",
|
||||
"createdAtMs": 1700000000000
|
||||
}
|
||||
"""
|
||||
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let before = try String(contentsOf: identityURL, encoding: .utf8)
|
||||
let legacy = [
|
||||
"version": 1,
|
||||
"deviceId": deviceId,
|
||||
"tokens": [
|
||||
role: [
|
||||
"token": token,
|
||||
"role": role,
|
||||
"scopes": scopes,
|
||||
"updatedAtMs": 1_700_000_000_000,
|
||||
],
|
||||
],
|
||||
] as [String: Any]
|
||||
let data = try JSONSerialization.data(withJSONObject: legacy, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: legacyURL)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||
|
||||
#expect(identity.deviceId != "stale-device-id")
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
private static func writeLegacyAuthSidecar(
|
||||
_ legacyURL: URL,
|
||||
deviceId: String,
|
||||
tokens: [String: (token: String, scopes: [String])]) throws
|
||||
{
|
||||
try FileManager.default.createDirectory(
|
||||
at: legacyURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let tokenEntries = tokens.map { role, value in
|
||||
(
|
||||
role,
|
||||
[
|
||||
"token": value.token,
|
||||
"role": role,
|
||||
"scopes": value.scopes,
|
||||
"updatedAtMs": 1_700_000_000_000,
|
||||
] as [String: Any]
|
||||
)
|
||||
}
|
||||
let tokenObject = Dictionary(uniqueKeysWithValues: tokenEntries)
|
||||
let legacy = [
|
||||
"version": 1,
|
||||
"deviceId": deviceId,
|
||||
"tokens": tokenObject,
|
||||
] as [String: Any]
|
||||
let data = try JSONSerialization.data(withJSONObject: legacy, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: legacyURL)
|
||||
}
|
||||
|
||||
private static func base64UrlDecode(_ value: String) -> Data? {
|
||||
@@ -77,7 +428,21 @@ struct DeviceIdentityStoreTests {
|
||||
return Data(base64Encoded: padded)
|
||||
}
|
||||
|
||||
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> String {
|
||||
private static func legacyRawIdentity() -> DeviceIdentity {
|
||||
let privateKey = Curve25519.Signing.PrivateKey()
|
||||
let publicKeyData = privateKey.publicKey.rawRepresentation
|
||||
let privateKeyData = privateKey.rawRepresentation
|
||||
let deviceId = SHA256.hash(data: publicKeyData).compactMap {
|
||||
String(format: "%02x", $0)
|
||||
}.joined()
|
||||
return DeviceIdentity(
|
||||
deviceId: deviceId,
|
||||
publicKey: publicKeyData.base64EncodedString(),
|
||||
privateKey: privateKeyData.base64EncodedString(),
|
||||
createdAtMs: 1_700_000_000_000)
|
||||
}
|
||||
|
||||
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> Data {
|
||||
let object: [String: Any] = [
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
@@ -85,11 +450,14 @@ struct DeviceIdentityStoreTests {
|
||||
"privateKeyPem": privateKeyPem,
|
||||
"createdAtMs": 1_700_000_000_000,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
return String(decoding: data, as: UTF8.self) + "\n"
|
||||
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
}
|
||||
|
||||
private static func pem(label: String, body: String) -> String {
|
||||
"-----BEGIN \(label)-----\n\(body)\n-----END \(label)-----\n"
|
||||
}
|
||||
|
||||
private static func privateKeyMarker(_ boundary: String) -> String {
|
||||
"-----\(boundary) \("PRIVATE" + " KEY")-----"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Talk
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
@@ -17,3 +18,4 @@ final class ElevenLabsTTSValidationTests: XCTestCase {
|
||||
XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe"))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -37,6 +37,9 @@ const bundledPluginIgnoredRuntimeDependencies = [
|
||||
"@azure/identity",
|
||||
"@clawdbot/lobster",
|
||||
"@discordjs/opus",
|
||||
"@earendil-works/pi-agent-core",
|
||||
"@earendil-works/pi-ai",
|
||||
"@earendil-works/pi-coding-agent",
|
||||
"@homebridge/ciao",
|
||||
"@lit/context",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm",
|
||||
@@ -45,6 +48,7 @@ const bundledPluginIgnoredRuntimeDependencies = [
|
||||
"@pierre/theme",
|
||||
"@tloncorp/tlon-skill",
|
||||
"@zed-industries/codex-acp",
|
||||
"audio-decode",
|
||||
"jiti",
|
||||
"json5",
|
||||
"lit",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
370da2e3a4253f00c3963a3ad8b57707ea3f67a8d0d394b7d2b96db4f3413d32 config-baseline.json
|
||||
6a66c70d36dacf5fd1a8b7e157d1ff4812e97f518c13ebc3190509df4c269f29 config-baseline.core.json
|
||||
a9102c0611b8170fac37853cc31771810f31757a9e3b2c6796bbd9625f9b9206 config-baseline.channel.json
|
||||
923a8cac695c752e51751cc2dea185a3fbe19d0015722f7ea1909f897dfbb898 config-baseline.plugin.json
|
||||
e903d5e935a075ad4fa4446964871b0347636e159a3db2fbcfe036bd303c074c config-baseline.json
|
||||
6a46df8f096703bc8bb0e4bc7f6d8e9cfce1d760c61ff1bd0c20ab7fe274004a config-baseline.core.json
|
||||
507acac5476b823f93bec2f6ab0061d023fbaca80e0d981fb916f9c6436f1f2a config-baseline.channel.json
|
||||
9279edd18923a2da92d38f2894f4881932189b974c2cb6a7d057a0f43f96b413 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
cf29066e9465cb5ac1387d1d482d0939b9176220ecc69964da9af1a471939269 plugin-sdk-api-baseline.json
|
||||
ab43993cf713a96b191c55cf89bb215c18ecdc2d8edf50f31369ce3b162c56e3 plugin-sdk-api-baseline.jsonl
|
||||
9a3ee218fb45e9dd0d4e98c59f9ea640f66983e8d6c35fa17ccb35866c039bce plugin-sdk-api-baseline.json
|
||||
b257d1adbe8fdbe31c418bbbf4246a6aa26d305a776905db2a3a7e2284ede3d1 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
"target": "消息生命周期重构"
|
||||
},
|
||||
{
|
||||
"source": "ACP lifecycle refactor",
|
||||
"target": "ACP 生命周期重构"
|
||||
"source": "Refactoring",
|
||||
"target": "重构"
|
||||
},
|
||||
{
|
||||
"source": "Channel message API",
|
||||
@@ -135,6 +135,14 @@
|
||||
"source": "Pi",
|
||||
"target": "Pi"
|
||||
},
|
||||
{
|
||||
"source": "Embedded agent runtime architecture",
|
||||
"target": "嵌入式 agent 运行时架构"
|
||||
},
|
||||
{
|
||||
"source": "Embedded agent runtime development workflow",
|
||||
"target": "嵌入式 agent 运行时开发工作流"
|
||||
},
|
||||
{
|
||||
"source": "Agent runtimes",
|
||||
"target": "Agent Runtimes"
|
||||
@@ -1118,5 +1126,13 @@
|
||||
{
|
||||
"source": "Z.AI (GLM)",
|
||||
"target": "Z.AI (GLM)"
|
||||
},
|
||||
{
|
||||
"source": "Kysely best practices",
|
||||
"target": "Kysely 最佳实践"
|
||||
},
|
||||
{
|
||||
"source": "Database-first state refactor",
|
||||
"target": "数据库优先状态重构"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -48,7 +48,7 @@ Token credentials (`type: "token"`) support inline `token` and/or `tokenRef`.
|
||||
|
||||
Agent auth inheritance is read-through. When an agent has no local profile, it
|
||||
can resolve profiles from the default/main agent store at runtime without
|
||||
copying secret material into its own `auth-profiles.json`.
|
||||
copying secret material into its own SQLite auth-profile row.
|
||||
|
||||
Explicit copy flows, such as `openclaw agents add`, use this portability policy:
|
||||
|
||||
@@ -68,11 +68,11 @@ the target agent signs in separately and creates its own local profile.
|
||||
credentials. They are valid when the target provider uses
|
||||
`models.providers.<id>.auth: "aws-sdk"` or plugin-owned Amazon Bedrock setup
|
||||
AWS SDK route. These profile ids may appear in `auth.order` and session
|
||||
overrides even when no matching entry exists in `auth-profiles.json`.
|
||||
overrides even when no matching entry exists in the SQLite auth-profile row.
|
||||
|
||||
Do not write `type: "aws-sdk"` into `auth-profiles.json`. If a legacy install
|
||||
has such a marker, `openclaw doctor --fix` moves it to `auth.profiles` and
|
||||
removes the marker from the credential store.
|
||||
Do not write `type: "aws-sdk"` into the SQLite auth-profile row. If a legacy
|
||||
install has such a marker, `openclaw doctor --fix` moves it to `auth.profiles`
|
||||
and removes the marker from the credential store.
|
||||
|
||||
## Explicit auth order filtering
|
||||
|
||||
@@ -86,8 +86,8 @@ removes the marker from the credential store.
|
||||
|
||||
## Probe target resolution
|
||||
|
||||
- Probe targets can come from auth profiles, environment credentials, or
|
||||
`models.json`.
|
||||
- Probe targets can come from auth profiles, environment credentials, or the
|
||||
stored model catalog.
|
||||
- If a provider has credentials but OpenClaw cannot resolve a probeable model
|
||||
candidate for it, `models status --probe` reports `status: no_model` with
|
||||
`reasonCode: no_model`.
|
||||
|
||||
@@ -40,9 +40,10 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
## How cron works
|
||||
|
||||
- Cron runs **inside the Gateway** process (not inside the model).
|
||||
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
|
||||
- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
|
||||
- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
|
||||
- Job definitions, runtime execution state, and run history persist in the shared SQLite state database at `~/.openclaw/state/openclaw.sqlite`, so restarts do not lose schedules.
|
||||
- Legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once by `openclaw doctor --fix` and renamed with a `.migrated` suffix.
|
||||
- The optional `cron.store` path is now a legacy import namespace and display hint, not a runtime JSON writer. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
|
||||
- If legacy import finds malformed `jobs.json` rows, valid jobs continue importing and the malformed raw rows are preserved in SQLite quarantine state for later repair or review.
|
||||
- All cron executions create [background task](/automation/tasks) records.
|
||||
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
|
||||
- One-shot jobs (`--at`) auto-delete after success by default.
|
||||
@@ -58,7 +59,7 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
<a id="maintenance"></a>
|
||||
|
||||
<Note>
|
||||
Task reconciliation for cron is runtime-owned first, durable-history-backed second: an active cron task stays live while the cron runtime still tracks that job as running, even if an old child session row still exists. Once the runtime stops owning the job and the 5-minute grace window expires, maintenance checks persisted run logs and job state for the matching `cron:<jobId>:<startedAt>` run. If that durable history shows a terminal result, the task ledger is finalized from it; otherwise Gateway-owned maintenance can mark the task `lost`. Offline CLI audit can recover from durable history, but it does not treat its own empty in-process active-job set as proof that a Gateway-owned cron run is gone.
|
||||
Task reconciliation for cron is runtime-owned first, durable-history-backed second: an active cron task stays live while the cron runtime still tracks that job as running, even if an old child session row still exists. Once the runtime stops owning the job and the 5-minute grace window expires, maintenance checks persisted SQLite run logs and job state for the matching `cron:<jobId>:<startedAt>` run. If that durable history shows a terminal result, the task ledger is finalized from it; otherwise Gateway-owned maintenance can mark the task `lost`. Offline CLI audit can recover from durable history, but it does not treat its own empty in-process active-job set as proof that a Gateway-owned cron run is gone.
|
||||
</Note>
|
||||
|
||||
## Schedule types
|
||||
@@ -444,15 +445,14 @@ Model override note:
|
||||
{
|
||||
cron: {
|
||||
enabled: true,
|
||||
store: "~/.openclaw/cron/jobs.json",
|
||||
maxConcurrentRuns: 8,
|
||||
store: "~/.openclaw/cron/jobs.json", // optional legacy import key
|
||||
maxConcurrentRuns: 1,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoffMs: [60000, 120000, 300000],
|
||||
retryOn: ["rate_limit", "overloaded", "network", "server_error"],
|
||||
},
|
||||
webhookToken: "replace-with-dedicated-webhook-token",
|
||||
sessionRetention: "24h",
|
||||
runLog: { maxBytes: "2mb", keepLines: 2000 },
|
||||
},
|
||||
}
|
||||
@@ -460,7 +460,9 @@ Model override note:
|
||||
|
||||
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
|
||||
|
||||
`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
|
||||
Cron data is keyed by the resolved `cron.store` value inside the shared SQLite state database. That value is a logical store key and legacy import path, not a runtime JSON write path. SQLite stores job definitions, pending slots, active markers, last-run metadata, run history, and the schedule identity used to invalidate stale pending slots after a job update.
|
||||
|
||||
Run `openclaw doctor --fix` once after upgrading from an older version so doctor can import and archive legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files. After import, existing stores no longer drive active cron jobs; future cron changes should go through the CLI or Gateway API.
|
||||
|
||||
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
@@ -472,7 +474,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Maintenance">
|
||||
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs.
|
||||
`cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs. Session rows are SQLite-backed and are not age/count-pruned.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -511,7 +513,7 @@ openclaw doctor
|
||||
<Accordion title="Cron or heartbeat appears to prevent /new-style rollover">
|
||||
- Daily and idle reset freshness is not based on `updatedAt`; see [Session management](/concepts/session#session-lifecycle).
|
||||
- Cron wakeups, heartbeat runs, exec notifications, and gateway bookkeeping may update the session row for routing/status, but they do not extend `sessionStartedAt` or `lastInteractionAt`.
|
||||
- For legacy rows created before those fields existed, OpenClaw can recover `sessionStartedAt` from the transcript JSONL session header when the file is still available. Legacy idle rows without `lastInteractionAt` use that recovered start time as their idle baseline.
|
||||
- For legacy rows created before those fields existed, OpenClaw can recover `sessionStartedAt` from the SQLite transcript session header after doctor migration. Legacy idle rows without `lastInteractionAt` use that recovered start time as their idle baseline.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Timezone gotchas">
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
title: "Hooks"
|
||||
---
|
||||
|
||||
Hooks are small scripts that run when something happens inside the Gateway. They can be discovered from directories and inspected with `openclaw hooks`. The Gateway loads internal hooks only after you enable hooks or configure at least one hook entry, hook pack, legacy handler, or extra hook directory.
|
||||
Hooks are small scripts that run when something happens inside the Gateway. They can be discovered from directories and inspected with `openclaw hooks`. The Gateway loads internal hooks only after you enable hooks or configure at least one hook entry, hook pack, or extra hook directory.
|
||||
|
||||
There are two kinds of hooks in OpenClaw:
|
||||
|
||||
@@ -190,7 +190,7 @@ Hooks are discovered from these directories, in order of increasing override pre
|
||||
|
||||
Workspace hooks can add new hook names but cannot override bundled, managed, or plugin-provided hooks with the same name.
|
||||
|
||||
The Gateway skips internal hook discovery on startup until internal hooks are configured. Enable a bundled or managed hook with `openclaw hooks enable <name>`, install a hook pack, or set `hooks.internal.enabled=true` to opt in. When you enable one named hook, the Gateway loads only that hook's handler; `hooks.internal.enabled=true`, extra hook directories, and legacy handlers opt into broad discovery.
|
||||
The Gateway skips internal hook discovery on startup until internal hooks are configured. Enable a bundled or managed hook with `openclaw hooks enable <name>`, install a hook pack, or set `hooks.internal.enabled=true` to opt in. When you enable one named hook, the Gateway loads only that hook's handler; `hooks.internal.enabled=true` and extra hook directories opt into broad discovery.
|
||||
|
||||
### Hook packs
|
||||
|
||||
@@ -208,7 +208,7 @@ Npm specs are registry-only (package name + optional exact version or dist-tag).
|
||||
| --------------------- | ------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| session-memory | `command:new`, `command:reset` | Saves session context to `<workspace>/memory/` |
|
||||
| bootstrap-extra-files | `agent:bootstrap` | Injects additional bootstrap files from glob patterns |
|
||||
| command-logger | `command` | Logs all commands to `~/.openclaw/logs/commands.log` |
|
||||
| command-logger | `command` | Logs all commands to the shared SQLite state database |
|
||||
| compaction-notifier | `session:compact:before`, `session:compact:after` | Sends visible chat notices when session compaction starts/ends |
|
||||
| boot-md | `gateway:startup` | Runs `BOOT.md` when the gateway starts |
|
||||
|
||||
@@ -249,7 +249,8 @@ Paths resolve relative to workspace. Only recognized bootstrap basenames are loa
|
||||
|
||||
### command-logger details
|
||||
|
||||
Logs every slash command to `~/.openclaw/logs/commands.log`.
|
||||
Logs every slash command to the `command_log_entries` table in
|
||||
`~/.openclaw/state/openclaw.sqlite`.
|
||||
|
||||
<a id="compaction-notifier"></a>
|
||||
|
||||
@@ -325,7 +326,7 @@ Extra hook directories:
|
||||
```
|
||||
|
||||
<Note>
|
||||
The legacy `hooks.internal.handlers` array config format is still supported for backwards compatibility, but new hooks should use the discovery-based system.
|
||||
The legacy `hooks.internal.handlers` array config format is not loaded by the Gateway. Run `openclaw doctor --fix` to detect stale config, then move each hook into a discovered hook directory with `HOOK.md` metadata.
|
||||
</Note>
|
||||
|
||||
## CLI reference
|
||||
|
||||
@@ -116,9 +116,9 @@ Example: three independent cron jobs that together form a "morning ops" routine.
|
||||
## Durable state and revision tracking
|
||||
|
||||
Each flow persists its own state and tracks revisions so progress survives gateway restarts. Revision tracking enables conflict detection when multiple sources attempt to advance the same flow concurrently.
|
||||
The flow registry uses SQLite with bounded write-ahead-log maintenance, including
|
||||
periodic and shutdown checkpoints, so long-running gateways do not retain
|
||||
unbounded `registry.sqlite-wal` sidecar files.
|
||||
The flow registry persists in the shared SQLite state database at
|
||||
`~/.openclaw/state/openclaw.sqlite`, using the same bounded write-ahead-log
|
||||
maintenance as the rest of OpenClaw runtime state.
|
||||
|
||||
## Cancel behavior
|
||||
|
||||
|
||||
@@ -249,8 +249,8 @@ openclaw tasks notify <lookup> state_changes
|
||||
|
||||
- ACP/subagent tasks check their backing child session.
|
||||
- Subagent tasks whose child session has a restart-recovery tombstone are marked lost instead of being treated as recoverable backing sessions.
|
||||
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
|
||||
- CLI tasks with run identity check the owning live run context, not just child-session or chat-session rows.
|
||||
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted SQLite cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
|
||||
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.
|
||||
|
||||
Completion cleanup is also runtime-aware:
|
||||
|
||||
@@ -306,7 +306,7 @@ Both `/status` and the `session_status` tool use a cleanup-aware task snapshot:
|
||||
Task records persist in SQLite at:
|
||||
|
||||
```
|
||||
$OPENCLAW_STATE_DIR/tasks/runs.sqlite
|
||||
$OPENCLAW_STATE_DIR/state/openclaw.sqlite
|
||||
```
|
||||
|
||||
The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts.
|
||||
|
||||
@@ -128,17 +128,19 @@ Example:
|
||||
|
||||
## Session storage
|
||||
|
||||
Session stores live under the state directory (default `~/.openclaw`):
|
||||
Canonical session metadata lives in SQLite:
|
||||
|
||||
- `~/.openclaw/agents/<agentId>/sessions/sessions.json`
|
||||
- JSONL transcripts live alongside the store
|
||||
- `~/.openclaw/state/openclaw.sqlite` registers agents and shared control-plane rows.
|
||||
- `~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite` stores that
|
||||
agent's session rows and transcript events.
|
||||
|
||||
You can override the store path via `session.store` and `{agentId}` templating.
|
||||
Legacy `sessions.json` indexes are imported by `openclaw doctor --fix` and
|
||||
removed after SQLite has the rows. Runtime metadata should go through the
|
||||
agent's SQLite database. Startup does not import or rewrite legacy session indexes.
|
||||
|
||||
Gateway and ACP session discovery also scans disk-backed agent stores under the
|
||||
default `agents/` root and under templated `session.store` roots. Discovered
|
||||
stores must stay inside that resolved agent root and use a regular
|
||||
`sessions.json` file. Symlinks and out-of-root paths are ignored.
|
||||
Gateway and ACP session discovery read SQLite metadata. JSONL transcript files
|
||||
are legacy doctor-import inputs or explicit export artifacts only; runtime code
|
||||
must not create, select, or bridge through transcript files or locators.
|
||||
|
||||
## WebChat behavior
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ Once DMs are working, you can set up your Discord server as a full workspace whe
|
||||
|
||||
In guild channels, normal replies post automatically by default. For shared always-on rooms, opt into `messages.groupChat.visibleReplies: "message_tool"` so the agent can lurk and only post when it decides a channel reply is useful. This works best with latest-generation, tool-reliable models such as GPT 5.5. Ambient room events stay quiet unless the tool sends. See [Ambient room events](/channels/ambient-room-events) for the full lurk-mode config.
|
||||
|
||||
If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event or opted into message-tool visible replies.
|
||||
This means the selected model should reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event, inspect the gateway verbose log or SQLite transcript for `didSendViaMessagingTool: false`, or use the config below to restore legacy automatic final replies for normal group requests.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Ask your agent">
|
||||
|
||||
@@ -85,8 +85,8 @@ Only the owner number (from `channels.whatsapp.allowFrom`, or the bot's own E.16
|
||||
|
||||
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
||||
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
||||
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.openclaw/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn't triggered a run yet.
|
||||
- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies are opted into message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins.
|
||||
- Session rows use keys like `agent:<agentId>:whatsapp:group:<jid>` in the per-agent database; a missing row just means the group hasn't triggered a run yet.
|
||||
- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies use the default message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/Signal/Slack/Telegram/WhatsApp/Zalo)"
|
||||
read_when:
|
||||
- Changing group chat behavior or mention gating
|
||||
- Scoping mentionPatterns to specific group conversations
|
||||
title: "Groups"
|
||||
sidebarTitle: "Groups"
|
||||
---
|
||||
@@ -47,13 +48,17 @@ For normal group/channel requests, OpenClaw defaults to `messages.groupChat.visi
|
||||
|
||||
Use `messages.groupChat.visibleReplies: "message_tool"` when a shared room should let the agent decide when to speak by calling `message(action=send)`. This works best for group rooms backed by latest-generation, tool-reliable models such as GPT 5.5. If the model misses that tool and returns substantive final text, OpenClaw keeps that final text private instead of posting it to the room.
|
||||
|
||||
Use `"automatic"` for weaker models or runtimes that do not reliably understand tool-only delivery. In automatic mode, the agent's final assistant text is the visible source reply path, so a model that cannot consistently call `message(action=send)` can still answer normally.
|
||||
|
||||
If the message tool is unavailable under the active tool policy, OpenClaw falls
|
||||
back to automatic visible replies instead of silently suppressing the response.
|
||||
`openclaw doctor` warns about this mismatch.
|
||||
|
||||
For direct chats and any other source event, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Internal WebChat direct turns default to automatic final-reply delivery so Pi and Codex receive the same visible-reply contract. Set `messages.visibleReplies: "message_tool"` to intentionally require `message(action=send)` for visible output. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
|
||||
|
||||
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.
|
||||
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, the prompt does not define a `NO_REPLY` contract. Doing nothing visible simply means not calling the message tool.
|
||||
|
||||
Plugin-owned conversation bindings are the exception. Once a plugin binds a thread and claims the inbound turn, the plugin's returned reply is the visible binding response; it does not need `message(action=send)`. That reply is plugin runtime output, not private model final text.
|
||||
|
||||
Typing indicators are still sent for direct group requests. Ambient always-on room events, when enabled, stay strict and quiet unless the agent calls the message tool.
|
||||
|
||||
@@ -290,7 +295,7 @@ Control how group/room messages are handled per channel:
|
||||
- `groupPolicy` is separate from mention-gating (which requires @mentions).
|
||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams/Zalo: use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||
- Signal: `groupAllowFrom` can match either the inbound Signal group id or the sender phone/UUID.
|
||||
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- DM pairing approvals (stored in SQLite pairing state) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `channels.slack.channels`.
|
||||
- Matrix: allowlist uses `channels.matrix.groups`. Prefer room IDs or aliases; joined-room name lookup is best-effort, and unresolved names are ignored at runtime. Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported.
|
||||
@@ -358,10 +363,91 @@ Replying to a bot message counts as an implicit mention when the channel support
|
||||
}
|
||||
```
|
||||
|
||||
## Scope configured mention patterns
|
||||
|
||||
Configured `mentionPatterns` are regex fallback triggers. Use them when the
|
||||
platform does not expose a native bot mention, or when you want plain text such
|
||||
as `openclaw:` to count as a mention. Native platform mentions are separate:
|
||||
when Discord, Slack, Telegram, Matrix, or another channel can prove the message
|
||||
explicitly mentioned the bot, that native mention still triggers even if
|
||||
configured regex patterns are denied.
|
||||
|
||||
By default, configured mention patterns apply everywhere that channel passes
|
||||
provider and conversation facts into mention detection. To keep broad patterns
|
||||
from waking the agent in every group, scope them per channel with
|
||||
`channels.<channel>.mentionPatterns`.
|
||||
|
||||
Use `mode: "deny"` when regex mention patterns should be off by default for a
|
||||
channel, then opt in specific rooms with `allowIn`:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\bopenclaw\\b", "\\bops bot\\b"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
mentionPatterns: {
|
||||
mode: "deny",
|
||||
allowIn: ["C0123OPS"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use the default `mode: "allow"` (or omit `mode`) when regex mention patterns
|
||||
should apply broadly, then turn them off in noisy rooms with `denyIn`:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\bopenclaw\\b"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
mentionPatterns: {
|
||||
denyIn: ["-1001234567890", "-1001234567890:topic:42"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Policy resolution:
|
||||
|
||||
| Field | Effect |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mode: "allow"` | Regex mention patterns are enabled unless the conversation ID is in `denyIn`. This is the default. |
|
||||
| `mode: "deny"` | Regex mention patterns are disabled unless the conversation ID is in `allowIn`. |
|
||||
| `allowIn` | Conversation IDs where regex mention patterns are enabled in deny mode. |
|
||||
| `denyIn` | Conversation IDs where regex mention patterns are disabled. `denyIn` wins over `allowIn` if both include the same ID. |
|
||||
|
||||
Supported scoped regex policy today:
|
||||
|
||||
| Channel | IDs used in `allowIn` / `denyIn` |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| Discord | Discord channel IDs. |
|
||||
| Matrix | Matrix room IDs. |
|
||||
| Slack | Slack channel IDs. |
|
||||
| Telegram | Group chat IDs, or `chatId:topic:threadId` for forum topics. |
|
||||
| WhatsApp | WhatsApp conversation IDs such as `123@g.us`. |
|
||||
|
||||
Account-level channel configs can set the same policy under
|
||||
`channels.<channel>.accounts.<accountId>.mentionPatterns` when that channel
|
||||
supports multiple accounts. Account policy takes precedence over the top-level
|
||||
channel policy for that account.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Mention gating notes">
|
||||
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Surfaces that provide explicit mentions still pass; configured regex patterns are a fallback.
|
||||
- `channels.<channel>.mentionPatterns.mode: "deny"` disables configured mention patterns by default for that channel; opt selected conversations back in with `allowIn`.
|
||||
- `channels.<channel>.mentionPatterns.denyIn` disables configured mention patterns for specific conversation IDs while native platform @mentions still pass.
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
- Allowlisting a group or sender does not disable mention gating; set that group's `requireMention` to `false` when all messages should trigger.
|
||||
|
||||
@@ -248,7 +248,7 @@ iMessage catchup is now available as an opt-in feature on the bundled plugin. On
|
||||
|
||||
There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover.
|
||||
|
||||
The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate.
|
||||
The reply cache lives in SQLite plugin state under `~/.openclaw/state/openclaw.sqlite`. Run `openclaw doctor --fix` after updating if an older `imessage/reply-cache.jsonl` file is still present.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [QQ Bot](/channels/qqbot) - QQ Bot API; private chat, group chat, and rich media (bundled plugin).
|
||||
- [Signal](/channels/signal) - signal-cli; privacy-focused.
|
||||
- [Slack](/channels/slack) - Bolt SDK; workspace apps.
|
||||
- [SMS](/channels/sms) - Twilio-backed SMS through the Gateway webhook (bundled plugin).
|
||||
- [Synology Chat](/channels/synology-chat) - Synology NAS Chat via outgoing+incoming webhooks (bundled plugin).
|
||||
- [Telegram](/channels/telegram) - Bot API via grammY; supports groups.
|
||||
- [Tlon](/channels/tlon) - Urbit-based messenger (bundled plugin).
|
||||
|
||||
@@ -20,21 +20,23 @@ You do not need to rename config keys or reinstall the plugin under a new name.
|
||||
|
||||
## What the migration does automatically
|
||||
|
||||
When the gateway starts, and when you run [`openclaw doctor --fix`](/gateway/doctor), OpenClaw tries to repair old Matrix state automatically.
|
||||
When you run [`openclaw doctor --fix`](/gateway/doctor), OpenClaw imports or repairs old Matrix state through the migration system. Runtime startup does not move legacy Matrix files; startup reads the SQLite-backed state created by doctor/migrate.
|
||||
Before any actionable Matrix migration step mutates on-disk state, OpenClaw creates or reuses a focused recovery snapshot.
|
||||
|
||||
When you use `openclaw update`, the exact trigger depends on how OpenClaw is installed:
|
||||
|
||||
- source installs run `openclaw doctor --fix` during the update flow, then restart the gateway by default
|
||||
- package-manager installs update the package, run a non-interactive doctor pass, then rely on the default gateway restart so startup can finish Matrix migration
|
||||
- if you use `openclaw update --no-restart`, startup-backed Matrix migration is deferred until you later run `openclaw doctor --fix` and restart the gateway
|
||||
- package-manager installs update the package, then run a non-interactive doctor pass before the normal gateway restart
|
||||
- if you use `openclaw update --no-restart`, rerun `openclaw doctor --fix` yourself before restarting the gateway
|
||||
|
||||
Automatic migration covers:
|
||||
|
||||
- creating or reusing a pre-migration snapshot under `~/Backups/openclaw-migrations/`
|
||||
- reusing your cached Matrix credentials
|
||||
- moving legacy top-level Matrix credentials to the selected named account
|
||||
- keeping the same account selection and `channels.matrix` config
|
||||
- moving the oldest flat Matrix sync store into the current account-scoped location
|
||||
- importing old Matrix sync stores into SQLite plugin state
|
||||
- importing old Matrix IndexedDB crypto snapshots into SQLite plugin blobs
|
||||
- moving the oldest flat Matrix crypto store into the current account-scoped location when the target account can be resolved safely
|
||||
- extracting a previously saved Matrix room-key backup decryption key from the old rust crypto store, when that key exists locally
|
||||
- reusing the most complete existing token-hash storage root for the same Matrix account, homeserver, and user when the access token changes later
|
||||
@@ -43,7 +45,7 @@ Automatic migration covers:
|
||||
|
||||
Snapshot details:
|
||||
|
||||
- OpenClaw writes a marker file at `~/.openclaw/matrix/migration-snapshot.json` after a successful snapshot so later startup and repair passes can reuse the same archive.
|
||||
- OpenClaw writes a marker file at `~/.openclaw/matrix/migration-snapshot.json` after a successful snapshot so later doctor/migration passes can reuse the same archive.
|
||||
- These automatic Matrix migration snapshots back up config + state only (`includeWorkspace: false`).
|
||||
- If Matrix only has warning-only migration state, for example because `userId` or `accessToken` is still missing, OpenClaw does not create the snapshot yet because no Matrix mutation is actionable.
|
||||
- If the snapshot step fails, OpenClaw skips Matrix migration for that run instead of mutating state without a recovery point.
|
||||
@@ -69,14 +71,14 @@ OpenClaw cannot automatically recover:
|
||||
|
||||
Current warning scope:
|
||||
|
||||
- custom Matrix plugin path installs are surfaced by both gateway startup and `openclaw doctor`
|
||||
- custom Matrix plugin path installs are surfaced by `openclaw doctor`
|
||||
|
||||
If your old installation had local-only encrypted history that was never backed up, some older encrypted messages may remain unreadable after the upgrade.
|
||||
|
||||
## Recommended upgrade flow
|
||||
|
||||
1. Update OpenClaw and the Matrix plugin normally.
|
||||
Prefer plain `openclaw update` without `--no-restart` so startup can finish the Matrix migration immediately.
|
||||
Prefer plain `openclaw update` so the update flow runs doctor before the gateway restarts.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
@@ -136,8 +138,8 @@ If your old installation had local-only encrypted history that was never backed
|
||||
|
||||
Encrypted migration is a two-stage process:
|
||||
|
||||
1. Startup or `openclaw doctor --fix` creates or reuses the pre-migration snapshot if encrypted migration is actionable.
|
||||
2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store through the active Matrix plugin install.
|
||||
1. `openclaw doctor --fix` creates or reuses the pre-migration snapshot if encrypted migration is actionable.
|
||||
2. `openclaw doctor --fix` inspects the old Matrix crypto store through the active Matrix plugin install.
|
||||
3. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending.
|
||||
4. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically.
|
||||
|
||||
@@ -165,7 +167,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
`Legacy Matrix state detected at ... but channels.matrix is not configured yet.`
|
||||
|
||||
- Meaning: old Matrix state exists, but OpenClaw cannot map it to a current Matrix account because Matrix is not configured.
|
||||
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Legacy Matrix state detected at ... but the new account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).`
|
||||
|
||||
@@ -175,22 +177,12 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
`Legacy Matrix state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
|
||||
|
||||
- Meaning: OpenClaw found one shared flat Matrix store, but it refuses to guess which named Matrix account should receive it.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Matrix legacy sync store not migrated because the target already exists (...)`
|
||||
|
||||
- Meaning: the new account-scoped location already has a sync or crypto store, so OpenClaw did not overwrite it automatically.
|
||||
- What to do: verify that the current account is the correct one before manually removing or moving the conflicting target.
|
||||
|
||||
`Failed migrating Matrix legacy sync store (...)` or `Failed migrating Matrix legacy crypto store (...)`
|
||||
|
||||
- Meaning: OpenClaw tried to move old Matrix state but the filesystem operation failed.
|
||||
- What to do: inspect filesystem permissions and disk state, then rerun `openclaw doctor --fix`.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Legacy Matrix encrypted state detected at ... but channels.matrix is not configured yet.`
|
||||
|
||||
- Meaning: OpenClaw found an old encrypted Matrix store, but there is no current Matrix config to attach it to.
|
||||
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Legacy Matrix encrypted state detected at ... but the account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).`
|
||||
|
||||
@@ -200,34 +192,29 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
`Legacy Matrix encrypted state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
|
||||
|
||||
- Meaning: OpenClaw found one shared flat legacy crypto store, but it refuses to guess which named Matrix account should receive it.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.`
|
||||
|
||||
- Meaning: OpenClaw detected old Matrix state, but the migration is still blocked on missing identity or credential data.
|
||||
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.`
|
||||
|
||||
- Meaning: OpenClaw found old encrypted Matrix state, but it could not load the helper entrypoint from the Matrix plugin that normally inspects that store.
|
||||
- What to do: reinstall or repair the Matrix plugin (`openclaw plugins install @openclaw/matrix`, or `openclaw plugins install ./path/to/local/matrix-plugin` for a repo checkout), then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
- What to do: reinstall or repair the Matrix plugin (`openclaw plugins install @openclaw/matrix`, or `openclaw plugins install ./path/to/local/matrix-plugin` for a repo checkout), then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Matrix plugin helper path is unsafe: ... Reinstall @openclaw/matrix and try again.`
|
||||
|
||||
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`- Failed creating a Matrix migration snapshot before repair: ...`
|
||||
|
||||
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
|
||||
|
||||
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
|
||||
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Failed migrating legacy Matrix client storage: ...`
|
||||
|
||||
- Meaning: the Matrix client-side fallback found old flat storage, but the move failed. OpenClaw now aborts that fallback instead of silently starting with a fresh store.
|
||||
- What to do: inspect filesystem permissions or conflicts, keep the old state intact, and retry after fixing the error.
|
||||
- What to do: resolve the backup error, then rerun `openclaw doctor --fix`.
|
||||
|
||||
`Matrix is installed from a custom path: ...`
|
||||
|
||||
|
||||
@@ -481,9 +481,9 @@ openclaw matrix devices prune-stale
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Crypto store">
|
||||
Matrix E2EE uses the official `matrix-js-sdk` Rust crypto path with `fake-indexeddb` as the IndexedDB shim. Crypto state persists to `crypto-idb-snapshot.json` (restrictive file permissions).
|
||||
Matrix E2EE uses the official `matrix-js-sdk` Rust crypto path with `fake-indexeddb` as the IndexedDB shim. OpenClaw persists the IndexedDB crypto snapshot into SQLite plugin blobs; older `crypto-idb-snapshot.json` files are imported by `openclaw doctor --fix`.
|
||||
|
||||
Encrypted runtime state lives under `~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/` and includes the sync store, crypto store, recovery key, IDB snapshot, thread bindings, and startup verification state. When the token changes but the account identity stays the same, OpenClaw reuses the best existing root so prior state remains visible.
|
||||
Account-scoped Matrix roots under `~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/` are now mainly migration anchors plus recovery-key storage. Runtime sync, thread binding, startup verification, and IndexedDB snapshot state live in SQLite. When the token changes but the account identity stays the same, OpenClaw reuses the best existing root so prior state remains visible.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -78,17 +78,20 @@ Access groups are documented in detail here: [Access groups](/channels/access-gr
|
||||
|
||||
### Where the state lives
|
||||
|
||||
Stored under `~/.openclaw/credentials/`:
|
||||
Stored in `~/.openclaw/state/openclaw.sqlite`:
|
||||
|
||||
- Pending requests: `<channel>-pairing.json`
|
||||
- Approved allowlist store:
|
||||
- Default account: `<channel>-allowFrom.json`
|
||||
- Non-default account: `<channel>-<accountId>-allowFrom.json`
|
||||
- Pending requests: `channel_pairing_requests`
|
||||
- Approved allowlist entries: `channel_pairing_allow_entries`, account-scoped by channel account ID
|
||||
|
||||
Account scoping behavior:
|
||||
|
||||
- Non-default accounts read/write only their scoped allowlist file.
|
||||
- Default account uses the channel-scoped unscoped allowlist file.
|
||||
- Non-default accounts read/write only their scoped allowlist entry.
|
||||
- Default account uses the `default` account entry.
|
||||
|
||||
Older `~/.openclaw/credentials/<channel>-pairing.json`,
|
||||
`<channel>-allowFrom.json`, and `<channel>-<accountId>-allowFrom.json` files
|
||||
are legacy import sources only. Run `openclaw doctor --fix` to import them into
|
||||
SQLite and remove the JSON files.
|
||||
|
||||
Treat these as sensitive (they gate access to your assistant).
|
||||
|
||||
|
||||
320
docs/channels/sms.md
Normal file
320
docs/channels/sms.md
Normal file
@@ -0,0 +1,320 @@
|
||||
---
|
||||
summary: "Twilio SMS channel setup, access controls, and webhook configuration"
|
||||
read_when:
|
||||
- You want to connect OpenClaw to SMS through Twilio
|
||||
- You need SMS webhook or allowlist setup
|
||||
title: "SMS"
|
||||
---
|
||||
|
||||
OpenClaw can receive and send SMS through a Twilio phone number or Messaging Service. The Gateway registers an inbound webhook route, validates Twilio request signatures by default, and sends replies back through Twilio's Messages API.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
Default DM policy for SMS is pairing.
|
||||
</Card>
|
||||
<Card title="Gateway security" icon="shield" href="/gateway/security">
|
||||
Review webhook exposure and sender access controls.
|
||||
</Card>
|
||||
<Card title="Channel troubleshooting" icon="wrench" href="/channels/troubleshooting">
|
||||
Cross-channel diagnostics and repair playbooks.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Before you begin
|
||||
|
||||
You need:
|
||||
|
||||
- A Twilio account with an SMS-capable phone number, or a Twilio Messaging Service.
|
||||
- The Twilio Account SID and Auth Token.
|
||||
- A public HTTPS URL that reaches your OpenClaw Gateway.
|
||||
- A sender policy choice: `pairing` for private use, `allowlist` for preapproved phone numbers, or `open` only for intentionally public SMS access.
|
||||
|
||||
Use one Twilio number for both SMS and Voice Call if the number has both capabilities. Configure the SMS webhook and Voice webhook separately in Twilio; this page only covers the SMS webhook.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
<Steps>
|
||||
<Step title="Create or choose a Twilio sender">
|
||||
In Twilio, open **Phone Numbers > Manage > Active numbers** and choose an SMS-capable number. Save:
|
||||
|
||||
- Account SID, for example `ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`
|
||||
- Auth Token
|
||||
- Sender phone number, for example `+15551234567`
|
||||
|
||||
If you use a Messaging Service instead of a fixed sender number, save the Messaging Service SID, for example `MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Configure the SMS channel">
|
||||
|
||||
Save this as `sms.patch.json5` and change the placeholders:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
enabled: true,
|
||||
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "twilio-auth-token",
|
||||
fromNumber: "+15551234567",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Apply it:
|
||||
|
||||
```bash
|
||||
openclaw config patch --file ./sms.patch.json5 --dry-run
|
||||
openclaw config patch --file ./sms.patch.json5
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Point Twilio at the Gateway webhook">
|
||||
In the Twilio phone number settings, open **Messaging** and set **A message comes in** to:
|
||||
|
||||
```text
|
||||
https://gateway.example.com/webhooks/sms
|
||||
```
|
||||
|
||||
Use HTTP `POST`. The default local path is `/webhooks/sms`; change `channels.sms.webhookPath` if you need a different route.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Start the Gateway and approve first sender">
|
||||
|
||||
```bash
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
Send a text message to the Twilio number. The first message creates a pairing request. Approve it:
|
||||
|
||||
```bash
|
||||
openclaw pairing list sms
|
||||
openclaw pairing approve sms <CODE>
|
||||
```
|
||||
|
||||
Pairing codes expire after 1 hour.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Config file
|
||||
|
||||
Use config-file setup when you want the channel definition to travel with the Gateway config:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
enabled: true,
|
||||
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "twilio-auth-token",
|
||||
fromNumber: "+15551234567",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Use env setup for single-account deployments where secrets come from the host environment:
|
||||
|
||||
```bash
|
||||
export TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
export TWILIO_AUTH_TOKEN="<twilio-auth-token>"
|
||||
export TWILIO_PHONE_NUMBER="+15551234567"
|
||||
export SMS_PUBLIC_WEBHOOK_URL="https://gateway.example.com/webhooks/sms"
|
||||
```
|
||||
|
||||
Then enable the channel in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`TWILIO_SMS_FROM` is accepted as an alias for `TWILIO_PHONE_NUMBER`. Use `TWILIO_MESSAGING_SERVICE_SID` instead of a phone-number sender when Twilio should choose the sender from a Messaging Service.
|
||||
|
||||
### Allowlist-only private number
|
||||
|
||||
Use `allowlist` when only known phone numbers should be able to talk to the agent:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
enabled: true,
|
||||
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "twilio-auth-token",
|
||||
fromNumber: "+15551234567",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15557654321"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Messaging Service sender
|
||||
|
||||
Use `messagingServiceSid` instead of `fromNumber` when Twilio should choose the sender through a Messaging Service:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
enabled: true,
|
||||
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "twilio-auth-token",
|
||||
messagingServiceSid: "MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If both `fromNumber` and `messagingServiceSid` are present after config and env resolution, `fromNumber` is used.
|
||||
|
||||
### Default outbound target
|
||||
|
||||
Set `defaultTo` when automation or agent-initiated delivery should have a default destination if a send flow omits an explicit target:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
enabled: true,
|
||||
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "twilio-auth-token",
|
||||
fromNumber: "+15551234567",
|
||||
defaultTo: "+15557654321",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Access control
|
||||
|
||||
`channels.sms.dmPolicy` controls direct SMS access:
|
||||
|
||||
- `pairing` (default)
|
||||
- `allowlist` (requires at least one sender in `allowFrom`)
|
||||
- `open` (requires `allowFrom` to include `"*"`)
|
||||
- `disabled`
|
||||
|
||||
`allowFrom` entries should be E.164 phone numbers such as `+15551234567`. `sms:` prefixes are accepted and normalized. For a private assistant, prefer `dmPolicy: "allowlist"` with explicit phone numbers.
|
||||
|
||||
## Sending SMS
|
||||
|
||||
Outbound SMS targets use the `sms:` service prefix with the SMS channel selected:
|
||||
|
||||
```bash
|
||||
openclaw message send --channel sms --target sms:+15551234567 --message "hello"
|
||||
```
|
||||
|
||||
When channel selection is implicit, `twilio-sms:+15551234567` selects this channel without taking over the existing channel-owned `sms:` service prefix used by iMessage.
|
||||
|
||||
```bash
|
||||
openclaw message send --target twilio-sms:+15551234567 --message "hello"
|
||||
```
|
||||
|
||||
The CLI requires an explicit `--target`. `defaultTo` is for automation and agent-initiated delivery paths where the target can be resolved from channel config.
|
||||
|
||||
Agent replies from inbound SMS conversations automatically go back to the sender through the configured Twilio sender.
|
||||
|
||||
SMS output is plain text. OpenClaw strips markdown, flattens fenced code blocks, preserves readable links, and chunks long replies before sending them through Twilio.
|
||||
|
||||
## Verify Setup
|
||||
|
||||
After the Gateway starts:
|
||||
|
||||
1. Confirm the Gateway log shows the SMS webhook route.
|
||||
2. Send an SMS to the Twilio number from your phone.
|
||||
3. Run `openclaw pairing list sms`.
|
||||
4. Approve the pairing code with `openclaw pairing approve sms <CODE>`.
|
||||
5. Send another SMS and confirm the agent replies.
|
||||
|
||||
For outbound-only testing, use:
|
||||
|
||||
```bash
|
||||
openclaw message send --channel sms --target sms:+15557654321 --message "OpenClaw SMS test"
|
||||
```
|
||||
|
||||
## Webhook security
|
||||
|
||||
By default, OpenClaw validates `X-Twilio-Signature` using `publicWebhookUrl` and `authToken`. Keep `publicWebhookUrl` byte-for-byte aligned with the URL configured in Twilio, including scheme, host, path, and query string.
|
||||
|
||||
For local tunnel testing only, you can set:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
dangerouslyDisableSignatureValidation: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Do not use disabled signature validation on a public Gateway.
|
||||
|
||||
## Multi-account config
|
||||
|
||||
Use `accounts` when you operate more than one Twilio number:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
sms: {
|
||||
accounts: {
|
||||
support: {
|
||||
enabled: true,
|
||||
accountSid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
authToken: "twilio-auth-token",
|
||||
fromNumber: "+15551234567",
|
||||
publicWebhookUrl: "https://gateway.example.com/webhooks/sms/support",
|
||||
webhookPath: "/webhooks/sms/support",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15557654321"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Each account should use a distinct `webhookPath`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Twilio returns 403 or OpenClaw rejects the webhook
|
||||
|
||||
Check that `publicWebhookUrl` exactly matches the URL configured in Twilio, including scheme, host, path, and query string. Twilio signs the public URL string, so proxy rewrites and alternate hostnames can break signature validation.
|
||||
|
||||
### No pairing request appears
|
||||
|
||||
Check the Twilio number's **Messaging** webhook URL and method. It must point to the SMS webhook URL and use `POST`. Also confirm the Gateway is reachable from the public internet or through your tunnel.
|
||||
|
||||
### Outbound sends fail
|
||||
|
||||
Confirm `accountSid`, `authToken`, and either `fromNumber` or `messagingServiceSid` are resolved. If you use a trial Twilio account, the destination number may need to be verified in Twilio before outbound SMS will send.
|
||||
|
||||
### Messages arrive but the agent does not answer
|
||||
|
||||
Check `dmPolicy` and `allowFrom`. With the default `pairing` policy, the sender must be approved before normal agent turns are processed.
|
||||
@@ -127,7 +127,7 @@ After a successful startup, OpenClaw caches the bot identity in the state direct
|
||||
`dmPolicy: "allowlist"` with empty `allowFrom` blocks all DMs and is rejected by config validation.
|
||||
Setup asks for numeric user IDs only.
|
||||
If you upgraded and your config contains `@username` allowlist entries, run `openclaw doctor --fix` to resolve them (best-effort; requires a Telegram bot token).
|
||||
If you previously relied on pairing-store allowlist files, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet).
|
||||
If you previously relied on pairing-store allowlist state, `openclaw doctor --fix` can recover entries into `channels.telegram.allowFrom` in allowlist flows (for example when `dmPolicy: "allowlist"` has no explicit IDs yet). Older pairing JSON files are imported into SQLite first.
|
||||
|
||||
For one-owner bots, prefer `dmPolicy: "allowlist"` with explicit numeric `allowFrom` IDs to keep access policy durable in config (instead of depending on previous pairing approvals).
|
||||
|
||||
@@ -587,7 +587,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`)
|
||||
- `react` (`chatId`, `messageId`, `emoji`)
|
||||
- `deleteMessage` (`chatId`, `messageId`)
|
||||
- `editMessage` (`chatId`, `messageId`, `content`)
|
||||
- `editMessage` (`chatId`, `messageId`, `content` or `caption`, optional `presentation` inline buttons; button-only edits update reply markup)
|
||||
- `createForumTopic` (`chatId`, `name`, optional `iconColor`, `iconCustomEmojiId`)
|
||||
|
||||
Channel message actions expose ergonomic aliases (`send`, `react`, `delete`, `edit`, `sticker`, `sticker-search`, `topic-create`).
|
||||
@@ -729,9 +729,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `Sticker.fileUniqueId`
|
||||
- `Sticker.cachedDescription`
|
||||
|
||||
Sticker cache file:
|
||||
Sticker cache storage:
|
||||
|
||||
- `~/.openclaw/telegram/sticker-cache.json`
|
||||
- SQLite plugin state in `~/.openclaw/state/openclaw.sqlite`
|
||||
|
||||
Stickers are described once (when possible) and cached to reduce repeated vision calls.
|
||||
|
||||
@@ -866,7 +866,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `channels.telegram.timeoutSeconds` overrides Telegram API client timeout (if unset, grammY default applies). Bot clients clamp configured values below the 60-second outbound text/typing request guard so grammY does not abort visible reply delivery before OpenClaw's transport guard and fallback can run. Long polling still uses a 45-second `getUpdates` request guard so idle polls are not abandoned indefinitely.
|
||||
- `channels.telegram.pollingStallThresholdMs` defaults to `120000`; tune between `30000` and `600000` only for false-positive polling-stall restarts.
|
||||
- group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables.
|
||||
- reply/quote/forward supplemental context is normalized into one selected conversation context window when the gateway has observed the parent messages; the observed-message cache is persisted beside the session store. Telegram only includes one shallow `reply_to_message` in updates, so chains older than the cache are limited to Telegram's current update payload.
|
||||
- reply/quote/forward supplemental context is normalized into one selected conversation context window when the gateway has observed the parent messages; the observed-message cache is persisted in SQLite plugin state. Telegram only includes one shallow `reply_to_message` in updates, so chains older than the cache are limited to Telegram's current update payload.
|
||||
- Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary.
|
||||
- DM history controls:
|
||||
- `channels.telegram.dmHistoryLimit`
|
||||
|
||||
@@ -266,7 +266,7 @@ content and identifiers.
|
||||
|
||||
Runtime behavior details:
|
||||
|
||||
- pairings are persisted in channel allow-store and merged with configured `allowFrom`
|
||||
- pairings are persisted in SQLite channel pairing state and merged with configured `allowFrom`
|
||||
- scheduled automation and heartbeat recipient fallback use explicit delivery targets or configured `allowFrom`; DM pairing approvals are not implicit cron or heartbeat recipients
|
||||
- if no allowlist is configured, the linked self number is allowed by default
|
||||
- OpenClaw never auto-pairs outbound `fromMe` DMs (messages you send to yourself from the linked device)
|
||||
|
||||
@@ -566,6 +566,13 @@ The repo wrapper refuses a stale Crabbox binary that does not advertise `blacksm
|
||||
node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox --timing-json --shell -- "pnpm test <path-or-filter>"
|
||||
```
|
||||
|
||||
Blacksmith-backed runs require Crabbox 0.22.0 or newer so the wrapper gets the current Testbox sync, queue, and cleanup behavior. When using the sibling checkout, rebuild the ignored local binary before timing or proof work:
|
||||
|
||||
```bash
|
||||
version="$(git -C ../crabbox describe --tags --always --dirty | sed 's/^v//')" \
|
||||
&& go build -C ../crabbox -trimpath -ldflags "-s -w -X github.com/openclaw/crabbox/internal/cli.version=${version}" -o bin/crabbox ./cmd/crabbox
|
||||
```
|
||||
|
||||
Changed gate:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -172,7 +172,8 @@ Notes:
|
||||
|
||||
- `main` cannot be deleted.
|
||||
- Without `--force`, interactive confirmation is required.
|
||||
- Workspace, agent state, and session transcript directories are moved to Trash, not hard-deleted.
|
||||
- Workspace and per-agent state directories are moved to Trash, not hard-deleted.
|
||||
- Session rows for the deleted agent are purged from SQLite.
|
||||
- When the Gateway is reachable, deletion is sent through the Gateway so config and session-store cleanup share the same writer as runtime traffic. If the Gateway cannot be reached, the CLI falls back to the offline local path.
|
||||
- If another agent's workspace is the same path, inside this workspace, or contains this workspace,
|
||||
the workspace is retained and `--json` reports `workspaceRetained`,
|
||||
|
||||
@@ -9,7 +9,7 @@ title: "Approvals"
|
||||
# `openclaw approvals`
|
||||
|
||||
Manage exec approvals for the **local host**, **gateway host**, or a **node host**.
|
||||
By default, commands target the local approvals file on disk. Use `--gateway` to target the gateway, or `--node` to target a specific node.
|
||||
By default, commands target the local approvals state in SQLite. Use `--gateway` to target the gateway, or `--node` to target a specific node.
|
||||
|
||||
Alias: `openclaw exec-approvals`
|
||||
|
||||
@@ -21,13 +21,13 @@ Related:
|
||||
## `openclaw exec-policy`
|
||||
|
||||
`openclaw exec-policy` is the local convenience command for keeping the requested
|
||||
`tools.exec.*` config and the local host approvals file aligned in one step.
|
||||
`tools.exec.*` config and the local host approvals state aligned in one step.
|
||||
|
||||
Use it when you want to:
|
||||
|
||||
- inspect the local requested policy, host approvals file, and effective merge
|
||||
- inspect the local requested policy, host approvals state, and effective merge
|
||||
- apply a local preset such as YOLO or deny-all
|
||||
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
|
||||
- synchronize local `tools.exec.*` and local exec approvals state
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -49,10 +49,10 @@ Output modes:
|
||||
Current scope:
|
||||
|
||||
- `exec-policy` is **local-only**
|
||||
- it updates the local config file and the local approvals file together
|
||||
- it updates the local config file and the local approvals state together
|
||||
- it does **not** push policy to the gateway host or a node host
|
||||
- `--host node` is rejected in this command because node exec approvals are fetched from the node at runtime and must be managed through node-targeted approvals commands instead
|
||||
- `openclaw exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from the local approvals file
|
||||
- `openclaw exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from local approvals state
|
||||
|
||||
If you need to edit remote host approvals directly, keep using `openclaw approvals set --gateway`
|
||||
or `openclaw approvals set --node <id|name|ip>`.
|
||||
@@ -73,9 +73,9 @@ openclaw approvals get --gateway
|
||||
|
||||
Precedence is intentional:
|
||||
|
||||
- the host approvals file is the enforceable source of truth
|
||||
- the host approvals state is the enforceable source of truth
|
||||
- requested `tools.exec` policy can narrow or broaden intent, but the effective result is still derived from the host rules
|
||||
- `--node` combines the node host approvals file with gateway `tools.exec` policy, because both still apply at runtime
|
||||
- `--node` combines the node host approvals state with gateway `tools.exec` policy, because both still apply at runtime
|
||||
- if gateway config is unavailable, the CLI falls back to the node approvals snapshot and notes that the final runtime policy could not be computed
|
||||
|
||||
## Replace approvals from a file
|
||||
@@ -123,7 +123,7 @@ openclaw approvals set --node <id|name|ip> --stdin <<'EOF'
|
||||
EOF
|
||||
```
|
||||
|
||||
This changes the **host approvals file** only. To keep the requested OpenClaw policy aligned, also set:
|
||||
This changes the **host approvals state** only. To keep the requested OpenClaw policy aligned, also set:
|
||||
|
||||
```bash
|
||||
openclaw config set tools.exec.host gateway
|
||||
@@ -169,8 +169,8 @@ openclaw approvals allowlist remove "~/Projects/**/bin/rg"
|
||||
|
||||
Targeting notes:
|
||||
|
||||
- no target flags means the local approvals file on disk
|
||||
- `--gateway` targets the gateway host approvals file
|
||||
- no target flags means the local approvals state
|
||||
- `--gateway` targets the gateway host approvals state
|
||||
- `--node` targets one node host after resolving id, name, IP, or id prefix
|
||||
|
||||
`allowlist add|remove` also supports:
|
||||
@@ -182,7 +182,7 @@ Targeting notes:
|
||||
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
|
||||
- `--agent` defaults to `"*"`, which applies to all agents.
|
||||
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
|
||||
- Approvals are stored per host in the SQLite state database. Legacy `~/.openclaw/exec-approvals.json` files are imported by `openclaw doctor --fix`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw backup` (create local backup archives)"
|
||||
summary: "CLI reference for `openclaw backup` (create, verify, and restore local backup archives)"
|
||||
read_when:
|
||||
- You want a first-class backup archive for local OpenClaw state
|
||||
- You want to preview which paths would be included before reset or uninstall
|
||||
@@ -8,28 +8,34 @@ title: "Backup"
|
||||
|
||||
# `openclaw backup`
|
||||
|
||||
Create a local backup archive for OpenClaw state, config, auth profiles, channel/provider credentials, sessions, and optionally workspaces.
|
||||
Create, verify, or restore a local backup archive for OpenClaw state, config,
|
||||
channel/provider credentials, sessions, auth profiles, and optionally
|
||||
workspaces.
|
||||
|
||||
```bash
|
||||
openclaw backup create
|
||||
openclaw backup create --output ~/Backups
|
||||
openclaw backup create --dry-run --json
|
||||
openclaw backup create --verify
|
||||
openclaw backup create --no-verify
|
||||
openclaw backup create --no-include-workspace
|
||||
openclaw backup create --only-config
|
||||
openclaw backup verify ./2026-03-09T08-00-00.000+08-00-openclaw-backup.tar.gz
|
||||
openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz
|
||||
openclaw backup restore ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz --dry-run
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The archive includes a `manifest.json` file with the resolved source paths and archive layout.
|
||||
- SQLite databases under the state directory are snapshotted with SQLite `VACUUM INTO`; live `*.sqlite-wal` and `*.sqlite-shm` sidecars are not archived directly.
|
||||
- Default output is a timestamped `.tar.gz` archive in the current working directory.
|
||||
- Timestamped backup filenames use your machine's local timezone and include the UTC offset.
|
||||
- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location.
|
||||
- Existing archive files are never overwritten.
|
||||
- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion.
|
||||
- `openclaw backup verify <archive>` validates that the archive contains exactly one root manifest, rejects traversal-style archive paths, and checks that every manifest-declared payload exists in the tarball.
|
||||
- `openclaw backup create --verify` runs that validation immediately after writing the archive.
|
||||
- `openclaw backup create` validates the written archive by default: it requires exactly one root manifest, rejects traversal-style archive paths, checks that every manifest-declared payload exists in the tarball, and runs SQLite integrity checks for manifest-declared database snapshots.
|
||||
- `openclaw backup create --no-verify` skips the post-write archive validation pass.
|
||||
- `openclaw backup restore <archive> --dry-run` validates the archive and previews the recorded source paths that would be replaced.
|
||||
- `openclaw backup restore <archive> --yes` restores the archive to the recorded source paths. Restore validates the archive before extracting, then replaces each manifest asset from the verifier-normalized payload.
|
||||
- `openclaw backup create --only-config` backs up just the active JSON config file.
|
||||
|
||||
## What gets backed up
|
||||
@@ -41,9 +47,8 @@ openclaw backup verify ./2026-03-09T08-00-00.000+08-00-openclaw-backup.tar.gz
|
||||
- The resolved `credentials/` directory when it exists outside the state directory
|
||||
- Workspace directories discovered from the current config, unless you pass `--no-include-workspace`
|
||||
|
||||
Model auth profiles are already part of the state directory under
|
||||
`agents/<agentId>/agent/auth-profiles.json`, so they are normally covered by the
|
||||
state backup entry.
|
||||
Model auth profiles are stored in SQLite under the state directory, so they are
|
||||
covered by the database snapshots in the state backup entry.
|
||||
|
||||
If you use `--only-config`, OpenClaw skips state, credentials-directory, and workspace discovery and archives only the active config file path.
|
||||
|
||||
@@ -86,7 +91,7 @@ Practical limits come from the local machine and destination filesystem:
|
||||
|
||||
- Available space for the temporary archive write plus the final archive
|
||||
- Time to walk large workspace trees and compress them into a `.tar.gz`
|
||||
- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup verify`
|
||||
- Time to rescan the archive after `openclaw backup create`, unless you pass `--no-verify`
|
||||
- Filesystem behavior at the destination path. OpenClaw prefers a no-overwrite hard-link publish step and falls back to exclusive copy when hard links are unsupported
|
||||
|
||||
Large workspaces are usually the main driver of archive size. If you want a smaller or faster backup, use `--no-include-workspace`.
|
||||
|
||||
@@ -205,6 +205,7 @@ File + dialog helpers:
|
||||
|
||||
```bash
|
||||
openclaw browser upload /tmp/openclaw/uploads/file.pdf --ref <ref>
|
||||
openclaw browser upload media://inbound/file.pdf --ref <ref>
|
||||
openclaw browser waitfordownload
|
||||
openclaw browser download <ref> report.pdf
|
||||
openclaw browser dialog --accept
|
||||
@@ -215,6 +216,10 @@ Managed Chrome profiles save ordinary click-triggered downloads into the OpenCla
|
||||
downloads directory (`/tmp/openclaw/downloads` by default, or the configured temp
|
||||
root). Use `waitfordownload` or `download` when the agent needs to wait for a
|
||||
specific file and return its path; those explicit waiters own the next download.
|
||||
Uploads accept files from the OpenClaw temp uploads root and OpenClaw-managed
|
||||
inbound media, including `media://inbound/<id>` and sandbox-relative
|
||||
`media/inbound/<id>` references. Nested media refs, traversal, and arbitrary
|
||||
local paths remain rejected.
|
||||
When an action opens a modal dialog, the action response returns
|
||||
`blockedByDialog` with `browserState.dialogs.pending`; pass `--dialog-id` to
|
||||
answer it directly. Dialogs handled outside OpenClaw appear under
|
||||
|
||||
@@ -80,7 +80,7 @@ Text output includes:
|
||||
- scope
|
||||
- suggested check-in text
|
||||
|
||||
JSON output also includes the commitment store path and full stored records.
|
||||
JSON output also includes the SQLite state database path and full stored records.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user