mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Revert "refactor: move runtime state to SQLite"
This reverts commit f91de52f0d.
This commit is contained in:
@@ -188,8 +188,7 @@ Live-provider debug template for direct AWS/Hetzner leases:
|
||||
|
||||
```sh
|
||||
mkdir -p .crabbox/logs
|
||||
CRABBOX_ENV_ALLOW=OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
pnpm crabbox:run -- --provider aws \
|
||||
pnpm crabbox:run -- --provider aws \
|
||||
--preflight \
|
||||
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
--timing-json \
|
||||
@@ -201,8 +200,9 @@ CRABBOX_ENV_ALLOW=OPENAI_API_KEY,OPENAI_BASE_URL \
|
||||
```
|
||||
|
||||
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
|
||||
`--sync-only` to delegated providers. Crabbox rejects them because the provider
|
||||
owns sync or command transport.
|
||||
`--sync-only` to delegated providers. Also do not pass `--script*` or
|
||||
`--fresh-pr` there. Crabbox rejects these because the provider owns sync or
|
||||
command transport.
|
||||
|
||||
## Efficient Bug E2E Verification
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
---
|
||||
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.
|
||||
2
.github/instructions/copilot.instructions.md
vendored
2
.github/instructions/copilot.instructions.md
vendored
@@ -4,7 +4,7 @@
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node 24+ (Bun also supported for dev/scripts)
|
||||
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
|
||||
- **Language**: TypeScript (ESM, strict mode)
|
||||
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
|
||||
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
|
||||
|
||||
77
.github/workflows/ci.yml
vendored
77
.github/workflows/ci.yml
vendored
@@ -1053,6 +1053,83 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
checks-node-compat:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-node-compat-node22
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
cache-key-suffix: "node22-pnpm11"
|
||||
install-bun: "false"
|
||||
|
||||
- name: Configure Node test resources
|
||||
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Node 22 compatibility
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: |
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
node openclaw.mjs --help
|
||||
node openclaw.mjs status --json --timeout 1
|
||||
pnpm test:build:singleton
|
||||
|
||||
checks-node-core-test-nondist-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24.x"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Clone publish repo
|
||||
env:
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -57,8 +57,6 @@ 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,13 +99,9 @@ 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/
|
||||
|
||||
@@ -126,6 +120,8 @@ USER.md
|
||||
!.agents/skills/gitcrawl/
|
||||
!.agents/skills/gitcrawl/**
|
||||
!.agents/skills/openclaw-docs/**
|
||||
!.agents/skills/openclaw-refactor-docs/
|
||||
!.agents/skills/openclaw-refactor-docs/**
|
||||
!.agents/skills/openclaw-debugging/
|
||||
!.agents/skills/openclaw-debugging/**
|
||||
!.agents/skills/openclaw-ghsa-maintainer/
|
||||
|
||||
@@ -92,9 +92,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
|
||||
- External boundaries: prefer `zod` or existing schema helpers.
|
||||
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
|
||||
- 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.
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -225,6 +225,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Docker: keep image builds on the source pnpm workspace policy so pnpm 11 can prune production dependencies without a Docker-only workspace rewrite.
|
||||
- Agents/compaction: restore info-level gateway logs for embedded compaction start, completion, and incomplete outcomes. (#71961) Thanks @rubencu.
|
||||
- Telegram: build reply-aware inbound turns through the shared channel context path so agents see the current reply target inline with the current message.
|
||||
- Telegram: recover legacy message cache files that mixed JSON-array and line-delimited entries so restarted gateways preserve reply-window context. (#80567)
|
||||
- Telegram: update the reply-context cache when messages are edited, so streamed bot replies appear in later agent context with their final text instead of the first draft.
|
||||
- Skills/Windows: normalize compacted skill prompt locations to forward slashes after home-prefix compaction so Windows skill paths remain readable by model file tools. (#52200) Thanks @chienchandler.
|
||||
- Control UI/Windows: update `@openclaw/fs-safe` so agent workspace file presence checks fall back correctly on Windows, preventing existing AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md, HEARTBEAT.md, and MEMORY.md files from showing as missing. Fixes #79953. Thanks @lovelefeng-glitch.
|
||||
- Memory: skip managed dreaming cron reconciliation warnings for ordinary cron and heartbeat hook contexts that cannot manage Gateway cron. (#77027) Thanks @rubencu.
|
||||
- Cron: treat Codex app-server turn acceptance, CLI process spawn, and tool starts as execution milestones, preventing isolated runs from tripping the early startup watchdog after work has begun.
|
||||
- Codex app-server: treat current-turn `<turn_aborted>` raw markers as terminal so interrupted native-tool turns release Discord agent sessions instead of waiting for the outer timeout.
|
||||
@@ -260,9 +264,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex app-server: report Codex-native tool execution to diagnostics so long-running native `bash`, web, file, and MCP tools no longer look like stale embedded runs to the watchdog. (#80217)
|
||||
- Codex app-server: refresh Codex account rate limits after subscription usage-limit failures so Discord and other channel replies can show the next reset time instead of saying Codex returned none. Thanks @pashpashpash.
|
||||
- Agents/auth: let Codex-backed OpenAI agent turns use `auth.order.openai` entries for Codex-compatible OAuth and API-key profiles while keeping existing `openai-codex` profile ordering valid.
|
||||
- Codex app-server: emit async `after_tool_call` observations for native tool completions not covered by the native hook relay so observability plugins can record Codex-native tools. (#80372) Thanks @VACInc.
|
||||
- Tasks: route group and channel task completions through the requester session so the parent agent can send the visible summary instead of stopping at a generic task-status line. Fixes #77251. (#77365) Thanks @funmerlin.
|
||||
- Telegram: preserve blank lines between manually indented bullet blocks and following numbered sections in rendered replies. Fixes #76998. Thanks @evgyur.
|
||||
- Agents/sandbox: allow read-only sandbox sessions to read the `/agent` workspace mount while keeping write/edit/apply_patch workspace-only guarded, restoring `read /agent/...` for `workspaceAccess: "ro"`. Fixes #39497. Thanks @teosborne.
|
||||
- Agents/sandbox: allow read-only sandbox sessions to read the `/agent` workspace mount while keeping write/edit/apply_patch workspace-only guarded, restoring `read /agent/...` for `workspaceAccess: "ro"`. Fixes #39497. Thanks @stainlu and @teosborne.
|
||||
- Slack: pass configured agent identity through draft preview sends so partial streaming replies keep custom username/avatar on the initial Slack message. Fixes #38235. (#38237) Thanks @lacymorrow.
|
||||
- Slack: support `allowBots: "mentions"` for bot-authored messages that mention the receiving bot, matching the documented Discord-style mode without accepting every bot message. Fixes #43587. (#43588) Thanks @raw34.
|
||||
- Slack: refresh private file URLs with `files.info` when inbound DM file events omit or stale attachment URLs, preventing file attachments from being dropped before media hydration. Fixes #50129. (#50200) Thanks @smartchainark.
|
||||
@@ -503,9 +508,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Image generation: include enabled generation providers such as fal in provider discovery even when another image provider is already active. Fixes #78141. Thanks @leoge007.
|
||||
- Slack: keep Socket Mode's native reconnect enabled so transient ping/pong misses can recover without forcing a full provider rebuild. Fixes #77933. Thanks @bmoran1022 and @brokemac79.
|
||||
- Cron: preserve cron timeout results when an isolated agent turn's `cron-nested` lane watchdog fires, preventing internal command-lane or model-fallback timeout text from being persisted. Fixes #77703. (#78168) Thanks @brokemac79 and @transxtech.
|
||||
- Gateway/sessions: remove the automatic cron session reaper and retired `cron.sessionRetention`; use `openclaw sessions cleanup` for session-row maintenance while cron run-log pruning remains under `cron.runLog`.
|
||||
- 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.
|
||||
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
|
||||
- ACPX/Codex: preserve trusted Codex project declarations when launching isolated Codex ACP sessions, avoiding interactive trust prompts in headless runs. Thanks @Stedyclaw.
|
||||
- ACPX/Codex: reap stale OpenClaw-owned ACPX/Codex ACP process trees on startup and after ACP session close, preventing orphaned harness processes from slowing the Gateway. Thanks @91wan.
|
||||
@@ -733,7 +735,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Status/Claude CLI: show `oauth (claude-cli)` for working Claude CLI OAuth runtime sessions instead of `unknown` when no local auth profile exists. Fixes #78632. Thanks @gorkem2020.
|
||||
- Memory search: preserve keyword-only hybrid FTS matches when vector scoring is unavailable or below the configured minimum score, so exact lexical hits are not dropped by weighted min-score filtering.
|
||||
- Heartbeat/async exec: remap cron-run session keys to agent-main (or `"global"` under `session.scope=global`) at the bash exec, ACP, gateway node-event, and CLI watchdog enqueue sites, and treat cron-run descendants as ephemeral for retention pruning, so async exec completion events land in the same queue the heartbeat drains instead of being stranded under the ephemeral cron-run key. Refs #52305. Thanks @Kaspre.
|
||||
- Wake protocol/system event CLI: type an optional `sessionKey` on `WakeParamsSchema` and add `--session-key` to `openclaw system event` so callers can target a specific session for async-task completion relays instead of always hitting the agent's main session. Refs #52305.
|
||||
- Wake protocol/system event CLI: type an optional `sessionKey` on `WakeParamsSchema`, add `--session-key` to `openclaw system event`, and keep cron enqueue/wake adapters resolving session-key-only targets symmetrically so callers can target a specific session for async-task completion relays instead of always hitting the agent's main session. Refs #52305. Thanks @Kaspre.
|
||||
- Exec approvals/node: let trusted backend node invokes complete no-device Control UI approvals after the original request connection changes, while keeping node, command, cwd, env, and allow-once replay bindings enforced. Fixes #78569. Thanks @naturedogdog.
|
||||
- Agents/subagents: keep background completion delivery on the requester-agent handoff/queue-retry path instead of raw-sending child results directly, and strip child-result wrapper or OpenClaw runtime-context scaffolding from queued outbound retries. Fixes #78531. Thanks @EthanSK.
|
||||
- Sandbox: recreate cached browser bridges when JavaScript-evaluation permission changes, keep failed prune removals tracked for retry, and make cross-device directory moves copy-then-commit without partially emptying the source on failure.
|
||||
@@ -2502,7 +2504,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channels/QQBot: re-evaluate routing bindings against the current runtime config on every inbound message instead of the snapshot captured at gateway start, so peer-specific bindings added via the CLI take effect without restarting the gateway. Fixes #69546. Thanks @F32138.
|
||||
- Channels/QQBot: re-evaluate routing bindings against the current runtime config on every inbound message instead of the snapshot captured at gateway start, so peer-specific bindings added via the CLI take effect without restarting the gateway. Fixes #69546 via #73567. Thanks @statxc and @F32138.
|
||||
- CLI/channel-setup: auto-skip the redundant "Install \<plugin\>?" confirmation when only one install source (npm or local) exists, show `download from <npm-spec>` hints for installable catalog channels in the picker, and suppress misleading npm hints for already-bundled channels. Fixes #73419. Thanks @sliverp.
|
||||
- BlueBubbles: tighten DM-vs-group routing across the outbound session route (`chat_guid:iMessage;-;...` DMs no longer classified as groups), reaction handling (drop group reactions that arrive without any chat identifier instead of synthesizing a `"group"` literal peerId), inbound `chatGuid` fallback (no longer fall back to the sender's DM chatGuid when resolving a group whose webhook omits chatGuid+chatId+chatIdentifier), and short message id resolution (carry caller chat context so a numeric short id reused after a long group conversation cannot silently resolve to a message in a different chat, with the same cross-chat guard applied to full GUIDs so retries cannot bypass it). Thanks @zqchris.
|
||||
- Gateway/sessions: clone cached session stores through the persisted JSON shape instead of `structuredClone`, reducing native-memory growth on the remaining #54155 Gateway RSS/session-accumulation path while keeping #54155 as the broader tracker and carrying forward the #45438 session-cache hypothesis. Thanks @vincentkoc and the #45438 reporters/commenters.
|
||||
|
||||
@@ -83,7 +83,7 @@ class NodeRuntime(
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val deviceAuthStore = DeviceAuthStore(appContext)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
@@ -104,6 +104,7 @@ class NodeRuntime(
|
||||
|
||||
private val cameraHandler: CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
@@ -113,6 +114,7 @@ class NodeRuntime(
|
||||
|
||||
private val debugHandler: DebugHandler =
|
||||
DebugHandler(
|
||||
appContext = appContext,
|
||||
identityStore = identityStore,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -12,6 +12,12 @@ data class DeviceAuthEntry(
|
||||
val updatedAtMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedDeviceAuthMetadata(
|
||||
val scopes: List<String> = emptyList(),
|
||||
val updatedAtMs: Long = 0L,
|
||||
)
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadEntry(
|
||||
deviceId: String,
|
||||
@@ -37,24 +43,28 @@ interface DeviceAuthTokenStore {
|
||||
}
|
||||
|
||||
class DeviceAuthStore(
|
||||
context: Context,
|
||||
private val prefs: SecurePrefs,
|
||||
) : DeviceAuthTokenStore {
|
||||
private val json = Json
|
||||
private val stateStore = OpenClawSQLiteStateStore(context)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun loadEntry(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): DeviceAuthEntry? {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val key = tokenKey(deviceId, role)
|
||||
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val row = stateStore.readDeviceAuthToken(normalizedDevice, normalizedRole) ?: return null
|
||||
val token = row.token.trim().takeIf { it.isNotEmpty() } ?: return null
|
||||
val metadata =
|
||||
prefs
|
||||
.getString(metadataKey(deviceId, role))
|
||||
?.let { raw ->
|
||||
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
|
||||
}
|
||||
return DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = decodeScopes(row.scopesJson),
|
||||
updatedAtMs = row.updatedAtMs,
|
||||
scopes = metadata?.scopes ?: emptyList(),
|
||||
updatedAtMs = metadata?.updatedAtMs ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,21 +74,17 @@ class DeviceAuthStore(
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val normalizedScopes = normalizeScopes(scopes)
|
||||
val latestDeviceId = stateStore.readLatestDeviceAuthDeviceId()
|
||||
if (latestDeviceId != null && latestDeviceId != normalizedDevice) {
|
||||
stateStore.deleteAllDeviceAuthTokens()
|
||||
}
|
||||
stateStore.upsertDeviceAuthToken(
|
||||
OpenClawSQLiteDeviceAuthTokenRow(
|
||||
deviceId = normalizedDevice,
|
||||
role = normalizedRole,
|
||||
token = token.trim(),
|
||||
scopesJson = json.encodeToString(normalizedScopes),
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
prefs.putString(
|
||||
metadataKey(deviceId, role),
|
||||
json.encodeToString(
|
||||
PersistedDeviceAuthMetadata(
|
||||
scopes = normalizedScopes,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,16 +92,28 @@ class DeviceAuthStore(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) {
|
||||
stateStore.deleteDeviceAuthToken(
|
||||
deviceId = normalizeDeviceId(deviceId),
|
||||
role = normalizeRole(role),
|
||||
)
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
prefs.remove(metadataKey(deviceId, role))
|
||||
}
|
||||
|
||||
private fun decodeScopes(raw: String): List<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw) }
|
||||
.getOrDefault(emptyList())
|
||||
.let(::normalizeScopes)
|
||||
private fun tokenKey(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun metadataKey(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app.gateway
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
@@ -17,8 +18,8 @@ data class DeviceIdentity(
|
||||
class DeviceIdentityStore(
|
||||
context: Context,
|
||||
) {
|
||||
private val stateStore = OpenClawSQLiteStateStore(context)
|
||||
private val legacyIdentityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
|
||||
|
||||
@Volatile private var cachedIdentity: DeviceIdentity? = null
|
||||
|
||||
@@ -27,14 +28,16 @@ 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()) {
|
||||
throw IllegalStateException(
|
||||
"Legacy OpenClaw device identity file exists. Run openclaw doctor --fix before starting runtime.",
|
||||
)
|
||||
}
|
||||
val fresh = generate()
|
||||
save(fresh)
|
||||
cachedIdentity = fresh
|
||||
@@ -108,33 +111,34 @@ class DeviceIdentityStore(
|
||||
null
|
||||
}
|
||||
|
||||
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 load(): DeviceIdentity? = readIdentity(identityFile)
|
||||
|
||||
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
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readIdentity(row: OpenClawSQLiteDeviceIdentityRow): DeviceIdentity? =
|
||||
PersistedDeviceIdentity(
|
||||
deviceId = row.deviceId,
|
||||
publicKeyPem = row.publicKeyPem,
|
||||
privateKeyPem = row.privateKeyPem,
|
||||
createdAtMs = row.createdAtMs,
|
||||
).toRuntimeIdentity()
|
||||
|
||||
private fun save(identity: DeviceIdentity) {
|
||||
val persisted = PersistedDeviceIdentity.fromRuntimeIdentity(identity)
|
||||
stateStore.writeDeviceIdentity(
|
||||
OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId = persisted.deviceId,
|
||||
publicKeyPem = persisted.publicKeyPem,
|
||||
privateKeyPem = persisted.privateKeyPem,
|
||||
createdAtMs = persisted.createdAtMs,
|
||||
),
|
||||
identityKey = IDENTITY_KEY,
|
||||
)
|
||||
try {
|
||||
identityFile.parentFile?.mkdirs()
|
||||
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||
} catch (_: Throwable) {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
@@ -164,6 +168,14 @@ 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)
|
||||
@@ -182,91 +194,7 @@ 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
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,6 +3,7 @@ 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
|
||||
@@ -18,6 +19,7 @@ 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,
|
||||
@@ -52,12 +54,16 @@ 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())
|
||||
android.util.Log.w("openclaw", "camera.snap[$ts]: $msg")
|
||||
logFile?.appendText("[$ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.snap: $msg")
|
||||
}
|
||||
try {
|
||||
logFile?.writeText("") // clear
|
||||
camLog("starting, params=$paramsJson")
|
||||
camLog("calling showCameraHud")
|
||||
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
|
||||
@@ -87,14 +93,18 @@ 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())
|
||||
android.util.Log.w("openclaw", "camera.clip[$ts]: $msg")
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $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,11 +3,13 @@ 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
|
||||
|
||||
private const val LOGCAT_PATH = "/system/bin/logcat"
|
||||
|
||||
class DebugHandler(
|
||||
private val appContext: Context,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
) {
|
||||
fun handleEd25519(): GatewaySession.InvokeResult {
|
||||
@@ -79,16 +81,24 @@ 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; output is bounded by -t and never staged to disk.
|
||||
// Run logcat on current dispatcher thread (no withContext) with file redirect
|
||||
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 = proc.inputStream.bufferedReader().use { it.readText().take(128000) }
|
||||
val normalizedRaw = raw.ifBlank { "(no output, finished=$finished)" }
|
||||
val raw =
|
||||
if (tmpFile.exists() && tmpFile.length() > 0) {
|
||||
tmpFile.readText().take(128000)
|
||||
} else {
|
||||
"(no output, finished=$finished, exists=${tmpFile.exists()})"
|
||||
}
|
||||
tmpFile.delete()
|
||||
val spamPatterns =
|
||||
listOf(
|
||||
"setRequestedFrameRate",
|
||||
@@ -109,7 +119,7 @@ class DebugHandler(
|
||||
"IncorrectContextUseViolation",
|
||||
)
|
||||
val sb = StringBuilder()
|
||||
for (line in normalizedRaw.lineSequence()) {
|
||||
for (line in raw.lineSequence()) {
|
||||
if (line.isBlank()) continue
|
||||
if (spamPatterns.any { line.contains(it) }) continue
|
||||
if (sb.length + line.length > 16000) {
|
||||
@@ -119,10 +129,18 @@ class DebugHandler(
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(line)
|
||||
}
|
||||
sb.toString().ifEmpty { "(all ${normalizedRaw.lines().size} lines filtered as spam)" }
|
||||
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
|
||||
} catch (e: Throwable) {
|
||||
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult)}}""")
|
||||
// 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)}}""")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -13,6 +12,7 @@ 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,6 +278,8 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val recentPackagesPref = "notifications.forwarding.recentPackages"
|
||||
private const val legacyRecentPackagesPref = "notifications.recentPackages"
|
||||
private const val recentPackagesLimit = 64
|
||||
|
||||
@Volatile private var activeService: DeviceNotificationListenerService? = null
|
||||
@@ -290,9 +292,32 @@ class DeviceNotificationListenerService : NotificationListenerService() {
|
||||
nodeEventSink = sink
|
||||
}
|
||||
|
||||
fun recentPackages(context: Context): List<String> =
|
||||
OpenClawSQLiteStateStore(context)
|
||||
.readRecentNotificationPackages(recentPackagesLimit)
|
||||
private fun recentPackagesPrefs(context: Context) = context.applicationContext.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
|
||||
private fun migrateLegacyRecentPackagesIfNeeded(context: Context) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun isAccessEnabled(context: Context): Boolean {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
@@ -341,13 +366,18 @@ 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 =
|
||||
recentPackages(service.applicationContext)
|
||||
.filter { it != normalized }
|
||||
prefs
|
||||
.getString(recentPackagesPref, null)
|
||||
.orEmpty()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != normalized }
|
||||
.take(recentPackagesLimit - 1)
|
||||
val updated = listOf(normalized) + existing
|
||||
OpenClawSQLiteStateStore(service.applicationContext)
|
||||
.replaceRecentNotificationPackages(updated, recentPackagesLimit)
|
||||
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ class GatewayBootstrapAuthTest {
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
val deviceId = DeviceIdentityStore(app).loadOrCreate().deviceId
|
||||
val authStore = DeviceAuthStore(app)
|
||||
val authStore = DeviceAuthStore(prefs)
|
||||
prefs.setGatewayToken("stale-shared-token")
|
||||
prefs.setGatewayBootstrapToken("stale-bootstrap-token")
|
||||
prefs.setGatewayPassword("stale-password")
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
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.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DeviceAuthStoreTest {
|
||||
@Before
|
||||
fun resetState() {
|
||||
File(RuntimeEnvironment.getApplication().filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveTokenPersistsNormalizedScopesMetadataInSQLite() {
|
||||
fun saveTokenPersistsNormalizedScopesMetadata() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val store = DeviceAuthStore(app)
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val store = DeviceAuthStore(prefs)
|
||||
|
||||
store.saveToken(
|
||||
deviceId = " Device-1 ",
|
||||
@@ -38,21 +39,25 @@ 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("operator-token", row?.token)
|
||||
assertEquals("""["operator.read","operator.write"]""", row?.scopesJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearTokenUpdatesSQLiteStore() {
|
||||
fun loadEntryReadsLegacyTokenWithoutMetadata() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val store = DeviceAuthStore(app)
|
||||
store.saveToken("device-1", "operator", "operator-token", scopes = listOf("operator.read"))
|
||||
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)
|
||||
|
||||
store.clearToken("device-1", "operator")
|
||||
|
||||
assertNull(store.loadEntry("device-1", "operator"))
|
||||
assertNull(OpenClawSQLiteStateStore(app).readDeviceAuthToken("device-1", "operator"))
|
||||
val entry = store.loadEntry("device-1", "operator")
|
||||
assertNotNull(entry)
|
||||
assertEquals("legacy-token", entry?.token)
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(emptyList<String>(), entry?.scopes)
|
||||
assertEquals(0L, entry?.updatedAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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 legacyJsonIdentityFailsClosedInsteadOfRotatingIdentity() {
|
||||
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 legacy JSON identity to block startup")
|
||||
} catch (error: IllegalStateException) {
|
||||
assertTrue(error.message?.contains("Run openclaw doctor --fix") == true)
|
||||
}
|
||||
|
||||
assertFalse(File(app.filesDir, "openclaw/state/openclaw.sqlite").exists())
|
||||
}
|
||||
|
||||
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): String =
|
||||
"-----BEGIN $label-----\n$body\n-----END $label-----"
|
||||
}
|
||||
@@ -3,48 +3,74 @@ 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 {
|
||||
@Before
|
||||
fun resetState() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
File(context.filesDir, "openclaw").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_readsSqliteRows() {
|
||||
fun recentPackages_migratesLegacyPreferenceKey() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
|
||||
listOf("com.example.one", "com.example.two"),
|
||||
)
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.clear()
|
||||
.putString("notifications.recentPackages", "com.example.one, com.example.two")
|
||||
.commit()
|
||||
|
||||
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() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs = context.getSharedPreferences("openclaw.secure", Context.MODE_PRIVATE)
|
||||
prefs
|
||||
.edit()
|
||||
.clear()
|
||||
.putString("notifications.forwarding.recentPackages", "com.example.new")
|
||||
.putString("notifications.recentPackages", "com.example.legacy")
|
||||
.commit()
|
||||
|
||||
val packages = DeviceNotificationListenerService.recentPackages(context)
|
||||
|
||||
assertEquals(listOf("com.example.new"), packages)
|
||||
assertNull(prefs.getString("notifications.recentPackages", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentPackages_trimsDedupesAndPreservesRecencyOrder() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
OpenClawSQLiteStateStore(context).replaceRecentNotificationPackages(
|
||||
listOf(" com.example.recent ", "", "com.example.other", "com.example.recent", "com.example.third"),
|
||||
)
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
@@ -275,7 +275,7 @@ class InvokeDispatcherTest {
|
||||
getNodeCanvasHostUrl = { null },
|
||||
getOperatorCanvasHostUrl = { null },
|
||||
),
|
||||
debugHandler = DebugHandler(DeviceIdentityStore(appContext)),
|
||||
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
|
||||
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
|
||||
isForeground = { true },
|
||||
cameraEnabled = { cameraEnabled },
|
||||
@@ -296,6 +296,7 @@ class InvokeDispatcherTest {
|
||||
|
||||
private fun newCameraHandler(appContext: Context): CameraHandler =
|
||||
CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = CameraCaptureManager(appContext),
|
||||
externalAudioCaptureActive = MutableStateFlow(false),
|
||||
showCameraHud = { _, _, _ -> },
|
||||
|
||||
@@ -2465,11 +2465,8 @@ extension NodeAppModel {
|
||||
struct SessionRow: Decodable {
|
||||
var key: String
|
||||
var updatedAt: Double?
|
||||
var deliveryContext: DeliveryContext?
|
||||
}
|
||||
struct DeliveryContext: Decodable {
|
||||
var channel: String?
|
||||
var to: String?
|
||||
var lastChannel: String?
|
||||
var lastTo: String?
|
||||
}
|
||||
struct SessionsListResult: Decodable {
|
||||
var sessions: [SessionRow]
|
||||
@@ -2492,13 +2489,11 @@ 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.deliveryContext?.channel) != nil
|
||||
&& normalize(row.deliveryContext?.to) != nil
|
||||
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
||||
}
|
||||
let selected = exactMatch
|
||||
let channel = normalize(selected?.deliveryContext?.channel)
|
||||
let to = normalize(selected?.deliveryContext?.to)
|
||||
let channel = normalize(selected?.lastChannel)
|
||||
let to = normalize(selected?.lastTo)
|
||||
|
||||
await MainActor.run {
|
||||
self.shareDeliveryChannel = channel
|
||||
|
||||
@@ -378,21 +378,21 @@ enum CommandResolver {
|
||||
CLI="node $PRJ/dist/index.js"
|
||||
node "$PRJ/dist/index.js" \(quotedArgs);
|
||||
else
|
||||
echo "Node >=24 required on remote host"; exit 127;
|
||||
echo "Node >=22 required on remote host"; exit 127;
|
||||
fi
|
||||
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/openclaw.mjs" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
CLI="node $PRJ/openclaw.mjs"
|
||||
node "$PRJ/openclaw.mjs" \(quotedArgs);
|
||||
else
|
||||
echo "Node >=24 required on remote host"; exit 127;
|
||||
echo "Node >=22 required on remote host"; exit 127;
|
||||
fi
|
||||
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/openclaw.js" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
CLI="node $PRJ/bin/openclaw.js"
|
||||
node "$PRJ/bin/openclaw.js" \(quotedArgs);
|
||||
else
|
||||
echo "Node >=24 required on remote host"; exit 127;
|
||||
echo "Node >=22 required on remote host"; exit 127;
|
||||
fi
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
CLI="pnpm --silent openclaw"
|
||||
|
||||
@@ -46,5 +46,6 @@ let modelCatalogReloadKey = "openclaw.modelCatalogReload"
|
||||
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 schedulerStoreKey: String?
|
||||
var schedulerStorePath: 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.schedulerStoreKey = status.storeKey
|
||||
self.schedulerStorePath = status.storePath
|
||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||
}
|
||||
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
||||
|
||||
@@ -71,8 +71,8 @@ extension CronSettings {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let storeKey = self.store.schedulerStoreKey, !storeKey.isEmpty {
|
||||
Text(storeKey)
|
||||
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
||||
Text(storePath)
|
||||
.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.schedulerStoreKey = "default"
|
||||
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
|
||||
@@ -43,15 +43,15 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionDatabase() {
|
||||
static func openSessionStore() {
|
||||
if AppStateStore.shared.connectionMode == .remote {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Remote mode"
|
||||
alert.informativeText = "Session database lives on the gateway host in remote mode."
|
||||
alert.informativeText = "Session store lives on the gateway host in remote mode."
|
||||
alert.runModal()
|
||||
return
|
||||
}
|
||||
let path = self.resolveSessionDatabasePath()
|
||||
let path = self.resolveSessionStorePath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if FileManager().fileExists(atPath: path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
@@ -191,8 +191,19 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func resolveSessionDatabasePath() -> String {
|
||||
SessionLoader.defaultDatabasePath
|
||||
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
|
||||
}
|
||||
|
||||
// MARK: - Sessions (thinking / verbose)
|
||||
@@ -233,8 +244,8 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func openSessionDatabaseInCode() {
|
||||
let path = SessionLoader.defaultDatabasePath
|
||||
static func openSessionStoreInCode() {
|
||||
let path = SessionLoader.defaultStorePath
|
||||
let proc = Process()
|
||||
proc.launchPath = "/usr/bin/env"
|
||||
proc.arguments = ["code", path]
|
||||
|
||||
@@ -19,7 +19,8 @@ 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 sessionDatabasePath: String = SessionLoader.defaultDatabasePath
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@State private var debugSendInFlight = false
|
||||
@State private var debugSendStatus: String?
|
||||
@State private var debugSendError: String?
|
||||
@@ -29,6 +30,7 @@ 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"
|
||||
@@ -67,7 +69,7 @@ struct DebugSettings: View {
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.reloadModels()
|
||||
self.refreshSessionDatabasePath()
|
||||
self.loadSessionStorePath()
|
||||
}
|
||||
.alert(item: self.$pendingKill) { listener in
|
||||
Alert(
|
||||
@@ -261,10 +263,28 @@ struct DebugSettings: View {
|
||||
.labelsHidden()
|
||||
.help("Controls the macOS app log verbosity.")
|
||||
|
||||
Text("Use Console.app or `log stream` for macOS app logs.")
|
||||
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)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,17 +400,25 @@ struct DebugSettings: View {
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Session database")
|
||||
self.gridLabel("Session store")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.sessionDatabasePath)
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path", text: self.$sessionStorePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
Text("Runtime session state is stored in the per-agent SQLite database.")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
@@ -731,8 +759,31 @@ struct DebugSettings: View {
|
||||
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
|
||||
}
|
||||
|
||||
private func refreshSessionDatabasePath() {
|
||||
self.sessionDatabasePath = SessionLoader.defaultDatabasePath
|
||||
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 var bindingOverride: Binding<String> {
|
||||
@@ -904,7 +955,8 @@ extension DebugSettings {
|
||||
view.modelsLoading = false
|
||||
view.modelsError = "Failed to load models"
|
||||
view.gatewayRootInput = "/tmp/openclaw"
|
||||
view.sessionDatabasePath = "/tmp/openclaw-agent.sqlite"
|
||||
view.sessionStorePath = "/tmp/sessions.json"
|
||||
view.sessionStoreSaveError = "Save failed"
|
||||
view.debugSendInFlight = true
|
||||
view.debugSendStatus = "Sent"
|
||||
view.debugSendError = "Failed"
|
||||
@@ -942,7 +994,7 @@ extension DebugSettings {
|
||||
_ = view.experimentsSection
|
||||
_ = view.gridLabel("Test")
|
||||
|
||||
view.refreshSessionDatabasePath()
|
||||
view.loadSessionStorePath()
|
||||
await view.reloadModels()
|
||||
}
|
||||
}
|
||||
|
||||
133
apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift
Normal file
133
apps/macos/Sources/OpenClaw/DiagnosticsFileLog.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
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,20 +226,17 @@ enum ExecApprovalsStore {
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
private static let defaultAutoAllowSkills = false
|
||||
private static let storeLock = NSRecursiveLock()
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
private static let fileLock = NSRecursiveLock()
|
||||
|
||||
private static func withStoreLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.storeLock.lock()
|
||||
defer { self.storeLock.unlock() }
|
||||
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.fileLock.lock()
|
||||
defer { self.fileLock.unlock() }
|
||||
return try body()
|
||||
}
|
||||
|
||||
static func databaseURL() -> URL {
|
||||
ExecApprovalsSQLiteStateStore.databaseURL()
|
||||
}
|
||||
|
||||
static func storeLocationForDisplay() -> String {
|
||||
ExecApprovalsSQLiteStateStore.storeLocationForDisplay()
|
||||
static func fileURL() -> URL {
|
||||
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||
}
|
||||
|
||||
static func socketPath() -> String {
|
||||
@@ -280,13 +277,30 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
self.withStoreLock {
|
||||
let raw = ExecApprovalsSQLiteStateStore.readRawState()
|
||||
self.withFileLock {
|
||||
let url = self.fileURL()
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsSnapshot(
|
||||
path: self.storeLocationForDisplay(),
|
||||
exists: raw != nil,
|
||||
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: [:])
|
||||
}()
|
||||
return ExecApprovalsSnapshot(
|
||||
path: url.path,
|
||||
exists: true,
|
||||
hash: self.hashRaw(raw),
|
||||
file: self.parseRawState(raw))
|
||||
file: decoded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,26 +320,54 @@ enum ExecApprovalsStore {
|
||||
agents: file.agents)
|
||||
}
|
||||
|
||||
static func loadState() -> ExecApprovalsFile {
|
||||
self.withStoreLock {
|
||||
self.parseRawState(ExecApprovalsSQLiteStateStore.readRawState())
|
||||
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 saveState(_ file: ExecApprovalsFile) {
|
||||
self.withStoreLock {
|
||||
static func saveFile(_ file: ExecApprovalsFile) {
|
||||
self.withFileLock {
|
||||
do {
|
||||
try ExecApprovalsSQLiteStateStore.writeRawState(self.encodeRawState(file))
|
||||
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)
|
||||
} catch {
|
||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func ensureState() -> ExecApprovalsFile {
|
||||
self.withStoreLock {
|
||||
let snapshot = self.readSnapshot()
|
||||
var file = self.normalizeIncoming(snapshot.file)
|
||||
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)
|
||||
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if path.isEmpty {
|
||||
@@ -336,26 +378,26 @@ enum ExecApprovalsStore {
|
||||
file.socket?.token = self.generateToken()
|
||||
}
|
||||
if file.agents == nil { file.agents = [:] }
|
||||
if !snapshot.exists || snapshot.hash != self.hashRaw(self.encodeRawState(file)) {
|
||||
self.saveState(file)
|
||||
if !existed || loadedHash != self.hashFile(file) {
|
||||
self.saveFile(file)
|
||||
}
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.ensureState()
|
||||
return self.resolveFromState(file, agentId: agentId)
|
||||
let file = self.ensureFile()
|
||||
return self.resolveFromFile(file, agentId: agentId)
|
||||
}
|
||||
|
||||
/// Read-only resolve: loads SQLite state without writing missing defaults.
|
||||
/// Read-only resolve: loads file without writing (no ensureFile side effects).
|
||||
/// Safe to call from background threads / off MainActor.
|
||||
static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.loadState()
|
||||
return self.resolveFromState(file, agentId: agentId)
|
||||
let file = self.loadFile()
|
||||
return self.resolveFromFile(file, agentId: agentId)
|
||||
}
|
||||
|
||||
private static func resolveFromState(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
|
||||
private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved {
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -378,7 +420,7 @@ enum ExecApprovalsStore {
|
||||
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||
let token = file.socket?.token ?? ""
|
||||
return ExecApprovalsResolved(
|
||||
url: self.databaseURL(),
|
||||
url: self.fileURL(),
|
||||
socketPath: socketPath,
|
||||
token: token,
|
||||
defaults: resolvedDefaults,
|
||||
@@ -388,7 +430,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
||||
let file = self.ensureState()
|
||||
let file = self.ensureFile()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
return ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -398,13 +440,13 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
file.defaults = defaults
|
||||
}
|
||||
}
|
||||
|
||||
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
mutate(&defaults)
|
||||
file.defaults = defaults
|
||||
@@ -412,7 +454,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
var agents = file.agents ?? [:]
|
||||
let key = self.agentKey(agentId)
|
||||
if agent.isEmpty {
|
||||
@@ -434,7 +476,7 @@ enum ExecApprovalsStore {
|
||||
return reason
|
||||
}
|
||||
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -456,7 +498,7 @@ enum ExecApprovalsStore {
|
||||
command: String,
|
||||
resolvedPath: String?)
|
||||
{
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -478,7 +520,7 @@ enum ExecApprovalsStore {
|
||||
@discardableResult
|
||||
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] {
|
||||
var rejected: [ExecAllowlistRejectedEntry] = []
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -493,7 +535,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||
self.updateState { file in
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
@@ -507,37 +549,30 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateState(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||
self.withStoreLock {
|
||||
var file = self.ensureState()
|
||||
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||
self.withFileLock {
|
||||
var file = self.ensureFile()
|
||||
mutate(&file)
|
||||
self.saveState(file)
|
||||
self.saveFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseRawState(_ raw: String?) -> ExecApprovalsFile {
|
||||
guard let data = raw?.data(using: .utf8) else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
private static func ensureSecureStateDirectory() {
|
||||
let url = OpenClawPaths.stateDirURL
|
||||
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
|
||||
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.secureStateDirPermissions],
|
||||
ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
let message =
|
||||
"exec approvals state dir permission hardening failed: \(error.localizedDescription)"
|
||||
self.logger
|
||||
.warning(
|
||||
"\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
@@ -557,6 +592,14 @@ 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 == "~" {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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? {
|
||||
OpenClawSQLiteStateStore.readExecApprovalsRaw(configKey: self.configKey)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -743,7 +743,7 @@ extension GatewayConnection {
|
||||
|
||||
struct CronSchedulerStatus: Decodable {
|
||||
let enabled: Bool
|
||||
let storeKey: String
|
||||
let storePath: String
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
|
||||
@@ -474,7 +474,7 @@ struct GeneralSettings: View {
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session database: \(snap.sessions.databasePath) (\(snap.sessions.count) entries)")
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let recent = snap.sessions.recent.first {
|
||||
|
||||
@@ -36,7 +36,7 @@ struct HealthSnapshot: Codable {
|
||||
}
|
||||
|
||||
struct Sessions: Codable {
|
||||
let databasePath: String
|
||||
let path: String
|
||||
let count: Int
|
||||
let recent: [SessionInfo]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ 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 {
|
||||
@@ -56,7 +60,9 @@ enum OpenClawLogging {
|
||||
private static let didBootstrap: Void = {
|
||||
LoggingSystem.bootstrap { label in
|
||||
let (subsystem, category) = Self.parseLabel(label)
|
||||
return OpenClawOSLogHandler(subsystem: subsystem, category: category)
|
||||
let osHandler = OpenClawOSLogHandler(subsystem: subsystem, category: category)
|
||||
let fileHandler = OpenClawFileLogHandler(label: label)
|
||||
return MultiplexLogHandler([osHandler, fileHandler])
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -187,3 +193,65 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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)
|
||||
@@ -274,13 +275,20 @@ 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.openSessionDatabase()
|
||||
DebugActions.openSessionStore()
|
||||
} label: {
|
||||
Label("Open Session Database", systemImage: "externaldrive")
|
||||
Label("Open Session Store", systemImage: "externaldrive")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
|
||||
@@ -322,7 +322,7 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.representedObject = row.key
|
||||
item.submenu = self.buildSubmenu(for: row)
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
@@ -815,7 +815,7 @@ extension MenuSessionsInjector {
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Submenus
|
||||
|
||||
private func buildSubmenu(for row: SessionRow) -> NSMenu {
|
||||
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let width = self.submenuWidth()
|
||||
|
||||
@@ -839,6 +839,24 @@ 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: "")
|
||||
@@ -1047,6 +1065,15 @@ 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 }
|
||||
|
||||
@@ -749,7 +749,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: snapshot.path,
|
||||
@@ -767,7 +767,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
||||
let current = ExecApprovalsStore.ensureState()
|
||||
let current = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
if snapshot.exists {
|
||||
if snapshot.hash.isEmpty {
|
||||
@@ -803,7 +803,7 @@ actor MacNodeRuntime {
|
||||
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
||||
|
||||
ExecApprovalsStore.saveState(normalized)
|
||||
ExecApprovalsStore.saveFile(normalized)
|
||||
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: nextSnapshot.path,
|
||||
|
||||
@@ -679,7 +679,7 @@ extension OnboardingView {
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
Installs a user-space Node 24+ runtime and the CLI (no Homebrew).
|
||||
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
|
||||
Rerun anytime to reinstall or update.
|
||||
""")
|
||||
.font(.footnote)
|
||||
@@ -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 are present
|
||||
in the OpenClaw SQLite state database. Then connect again if needed.
|
||||
On your gateway host: install/update the `openclaw` package and make sure credentials exist
|
||||
(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.
|
||||
""",
|
||||
systemImage: "network")
|
||||
Divider()
|
||||
|
||||
@@ -4,8 +4,9 @@ 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 fileLock = NSRecursiveLock()
|
||||
private nonisolated(unsafe) static var configHealthState: [String: Any] = [:]
|
||||
|
||||
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.fileLock.lock()
|
||||
@@ -65,6 +66,7 @@ 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)
|
||||
|
||||
@@ -95,21 +97,88 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
let blocking = self.configWriteBlockingReasons(suspicious)
|
||||
if !blocking.isEmpty {
|
||||
_ = self.persistRejectedConfigWrite(data: data, configURL: url)
|
||||
let rejectedPath = 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
|
||||
}
|
||||
}
|
||||
@@ -392,12 +461,43 @@ 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] {
|
||||
self.configHealthState
|
||||
let url = self.configHealthStateURL()
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private static func writeConfigHealthState(_ root: [String: Any]) {
|
||||
self.configHealthState = root
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private static func configHealthEntry(state: [String: Any], configPath: String) -> [String: Any] {
|
||||
@@ -512,6 +612,16 @@ 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: "-")
|
||||
@@ -578,14 +688,130 @@ enum OpenClawConfigFile {
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.persistClobberedSnapshot(
|
||||
let backup = self.readConfigFingerprint(
|
||||
at: configURL.deletingLastPathComponent().appendingPathComponent("\(configURL.lastPathComponent).bak"))
|
||||
let clobberedPath = 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,5 +1,4 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
@@ -27,9 +26,17 @@ 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()
|
||||
self.records = Self.loadRecords(from: Self.recordPath)
|
||||
}
|
||||
|
||||
func sweep(mode: AppState.ConnectionMode) async {
|
||||
@@ -75,6 +82,7 @@ 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(
|
||||
@@ -393,27 +401,16 @@ actor PortGuardian {
|
||||
return await self.probeGatewayHealth(port: port)
|
||||
}
|
||||
|
||||
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 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 func save() {
|
||||
try? OpenClawSQLiteStateStore.replacePortGuardianRecords(
|
||||
self.records.map { record in
|
||||
OpenClawSQLitePortGuardianRecord(
|
||||
port: record.port,
|
||||
pid: record.pid,
|
||||
command: record.command,
|
||||
mode: record.mode,
|
||||
timestamp: record.timestamp)
|
||||
})
|
||||
guard let data = try? JSONEncoder().encode(self.records) else { return }
|
||||
try? data.write(to: Self.recordPath, options: [.atomic])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error {
|
||||
|
||||
enum RuntimeLocator {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime")
|
||||
private static let minNode = RuntimeVersion(major: 24, minor: 0, patch: 0)
|
||||
private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0)
|
||||
|
||||
static func resolve(
|
||||
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
|
||||
@@ -91,7 +91,7 @@ enum RuntimeLocator {
|
||||
switch error {
|
||||
case let .notFound(searchPaths):
|
||||
[
|
||||
"openclaw needs Node >=24.0.0 but found no runtime.",
|
||||
"openclaw needs Node >=22.16.0 but found no runtime.",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Install Node: https://nodejs.org/en/download",
|
||||
].joined(separator: "\n")
|
||||
@@ -105,7 +105,7 @@ enum RuntimeLocator {
|
||||
[
|
||||
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Try reinstalling or pinning a supported version (Node >=24.0.0).",
|
||||
"Try reinstalling or pinning a supported version (Node >=22.16.0).",
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)])
|
||||
params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)])
|
||||
}
|
||||
|
||||
static func compactSession(key: String, maxLines: Int = 400) async throws {
|
||||
@@ -57,4 +57,35 @@ 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,7 +28,7 @@ struct GatewaySessionEntryRecord: Codable {
|
||||
|
||||
struct GatewaySessionsListResponse: Codable {
|
||||
let ts: Double?
|
||||
let databasePath: String
|
||||
let path: String
|
||||
let count: Int
|
||||
let defaults: GatewaySessionDefaultsRecord?
|
||||
let sessions: [GatewaySessionEntryRecord]
|
||||
@@ -245,7 +245,7 @@ enum SessionLoadError: LocalizedError {
|
||||
}
|
||||
|
||||
struct SessionStoreSnapshot {
|
||||
let databasePath: String
|
||||
let storePath: String
|
||||
let defaults: SessionDefaults
|
||||
let rows: [SessionRow]
|
||||
}
|
||||
@@ -255,9 +255,9 @@ enum SessionLoader {
|
||||
static let fallbackModel = "claude-opus-4-6"
|
||||
static let fallbackContextTokens = 200_000
|
||||
|
||||
static let defaultDatabasePath = standardize(
|
||||
static let defaultStorePath = standardize(
|
||||
OpenClawPaths.stateDirURL
|
||||
.appendingPathComponent("agents/main/agent/openclaw-agent.sqlite").path)
|
||||
.appendingPathComponent("sessions/sessions.json").path)
|
||||
|
||||
static func loadSnapshot(
|
||||
activeMinutes: Int? = nil,
|
||||
@@ -326,7 +326,7 @@ enum SessionLoader {
|
||||
model: model)
|
||||
}.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
|
||||
|
||||
return SessionStoreSnapshot(databasePath: decoded.databasePath, defaults: defaults, rows: rows)
|
||||
return SessionStoreSnapshot(storePath: decoded.path, defaults: defaults, rows: rows)
|
||||
}
|
||||
|
||||
static func loadRows() async throws -> [SessionRow] {
|
||||
|
||||
@@ -53,6 +53,11 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ enum VoiceWakeForwarder {
|
||||
struct SessionRouteEntry: Decodable, Equatable {
|
||||
let key: String
|
||||
let channel: String?
|
||||
let lastChannel: String?
|
||||
let lastTo: String?
|
||||
let deliveryContext: DeliveryContext?
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ enum VoiceWakeForwarder {
|
||||
let parsedRoute = self.parseSessionKeyRoute(sessionKey)
|
||||
let channelRaw = self.firstNonEmpty(
|
||||
routeEntry?.deliveryContext?.channel,
|
||||
routeEntry?.lastChannel,
|
||||
routeEntry?.channel,
|
||||
parsedRoute?.channel)
|
||||
let channel = channelRaw
|
||||
@@ -89,6 +92,7 @@ enum VoiceWakeForwarder {
|
||||
?? .webchat
|
||||
let to = self.firstNonEmpty(
|
||||
routeEntry?.deliveryContext?.to,
|
||||
routeEntry?.lastTo,
|
||||
parsedRoute?.to)
|
||||
|
||||
return ForwardOptions(
|
||||
|
||||
@@ -225,6 +225,10 @@ 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()
|
||||
@@ -255,6 +259,7 @@ 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
|
||||
@@ -562,6 +567,7 @@ 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") }
|
||||
}
|
||||
@@ -571,6 +577,7 @@ actor VoiceWakeRuntime {
|
||||
}
|
||||
self.listeningState = .voiceWake
|
||||
self.isCapturing = true
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
||||
self.capturedTranscript = command
|
||||
self.committedTranscript = ""
|
||||
self.volatileTranscript = command
|
||||
@@ -646,7 +653,9 @@ actor VoiceWakeRuntime {
|
||||
self.captureTask = nil
|
||||
|
||||
let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.info("voicewake capture finalized len=\(finalTranscript.count)")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [
|
||||
"finalLen": "\(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,
|
||||
databasePath: decoded.databasePath,
|
||||
path: decoded.path,
|
||||
count: decoded.count,
|
||||
defaults: defaults,
|
||||
sessions: decoded.sessions)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 v24.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -18,20 +17,16 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure state stores approvals in sqlite without json sidecar`() async throws {
|
||||
try await self.withTempStateDir { stateDir in
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
let firstSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
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)
|
||||
|
||||
_ = ExecApprovalsStore.ensureState()
|
||||
let secondSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
#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)
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,38 +66,24 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure state hardens state directory permissions`() async throws {
|
||||
func `ensure file 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.ensureState()
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let attrs = try FileManager().attributesOfItem(atPath: stateDir.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
|
||||
#expect(permissions & 0o777 == 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
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))
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":{"databasePath":"/tmp/openclaw-agent.sqlite","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":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
"""
|
||||
|
||||
@Test func `decodes clean JSON`() {
|
||||
|
||||
@@ -25,7 +25,7 @@ struct HealthStoreStateTests {
|
||||
channelOrder: ["whatsapp"],
|
||||
channelLabels: ["whatsapp": "WhatsApp"],
|
||||
heartbeatSeconds: 60,
|
||||
sessions: .init(databasePath: "/tmp/openclaw-agent.sqlite", count: 0, recent: []))
|
||||
sessions: .init(path: "/tmp/sessions.json", 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(
|
||||
databasePath: "/tmp/openclaw-agent.sqlite",
|
||||
storePath: "/tmp/sessions.json",
|
||||
defaults: defaults,
|
||||
rows: rows)
|
||||
injector.setTestingSnapshot(snapshot, errorText: nil)
|
||||
|
||||
@@ -11,23 +11,6 @@ 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()
|
||||
@@ -138,11 +121,11 @@ struct OpenClawConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict does not write config state sidecars`() async throws {
|
||||
func `save dict appends config audit log`() 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)
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -157,8 +140,25 @@ 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)
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,11 +268,11 @@ struct OpenClawConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `load dict preserves suspicious out-of-band clobbers without state sidecars`() async throws {
|
||||
func `load dict audits suspicious out-of-band clobbers`() 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)
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -306,16 +306,31 @@ struct OpenClawConfigFileTests {
|
||||
let loaded = OpenClawConfigFile.loadDict()
|
||||
#expect((loaded["gateway"] as? [String: Any]) == nil)
|
||||
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
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"))
|
||||
|
||||
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)
|
||||
let clobberedPath = auditRoot?["clobberedPath"] as? String
|
||||
#expect(clobberedPath != nil)
|
||||
if let clobberedPath {
|
||||
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
|
||||
#expect(preserved == clobbered)
|
||||
}
|
||||
}
|
||||
@@ -324,11 +339,11 @@ struct OpenClawConfigFileTests {
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `save dict preserves gateway auth without audit sidecar`() async throws {
|
||||
func `save dict records preserved gateway auth in audit`() 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)
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -364,8 +379,14 @@ 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)
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.audit.path))
|
||||
#expect(!FileManager().fileExists(atPath: sidecars.health.path))
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +396,7 @@ struct OpenClawConfigFileTests {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let sidecars = self.legacyConfigSidecarURLs(in: stateDir)
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
@@ -407,16 +428,21 @@ struct OpenClawConfigFileTests {
|
||||
let after = try String(contentsOf: configPath, encoding: .utf8)
|
||||
#expect(after == before)
|
||||
|
||||
#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 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)
|
||||
let mode = attributes[.posixPermissions] as? NSNumber
|
||||
#expect(mode?.intValue == 0o600)
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,7 @@ struct RuntimeLocatorTests {
|
||||
@Test func `resolve succeeds with valid node`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v24.0.0
|
||||
echo v22.16.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
@@ -25,13 +25,13 @@ struct RuntimeLocatorTests {
|
||||
return
|
||||
}
|
||||
#expect(res.path == node.path)
|
||||
#expect(res.version == RuntimeVersion(major: 24, minor: 0, patch: 0))
|
||||
#expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0))
|
||||
}
|
||||
|
||||
@Test func `resolve fails on boundary below minimum`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v23.9.9
|
||||
echo v22.15.9
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
@@ -39,8 +39,8 @@ struct RuntimeLocatorTests {
|
||||
Issue.record("Expected unsupported error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(found == RuntimeVersion(major: 23, minor: 9, patch: 9))
|
||||
#expect(required == RuntimeVersion(major: 24, minor: 0, patch: 0))
|
||||
#expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9))
|
||||
#expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ struct RuntimeLocatorTests {
|
||||
|
||||
@Test func `describe failure includes paths`() {
|
||||
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
|
||||
#expect(msg.contains("Node >=24.0.0"))
|
||||
#expect(msg.contains("Node >=22.16.0"))
|
||||
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
|
||||
|
||||
let parseMsg = RuntimeLocator.describeFailure(
|
||||
@@ -85,7 +85,7 @@ struct RuntimeLocatorTests {
|
||||
raw: "garbage",
|
||||
path: "/usr/local/bin/node",
|
||||
searchPaths: ["/usr/local/bin"]))
|
||||
#expect(parseMsg.contains("Node >=24.0.0"))
|
||||
#expect(parseMsg.contains("Node >=22.16.0"))
|
||||
}
|
||||
|
||||
@Test func `runtime version parses with leading V and metadata`() {
|
||||
|
||||
@@ -8,7 +8,7 @@ struct SettingsViewSmokeTests {
|
||||
@Test func `cron settings builds body`() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.schedulerStoreKey = "default"
|
||||
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
|
||||
|
||||
let job1 = CronJob(
|
||||
id: "job-1",
|
||||
|
||||
@@ -25,6 +25,8 @@ import Testing
|
||||
let entry = VoiceWakeForwarder.SessionRouteEntry(
|
||||
key: "agent:main:telegram:group:6812765697",
|
||||
channel: "telegram",
|
||||
lastChannel: "telegram",
|
||||
lastTo: "telegram:6812765697",
|
||||
deliveryContext: .init(channel: "telegram", to: "telegram:6812765697"))
|
||||
|
||||
let opts = VoiceWakeForwarder.forwardOptions(
|
||||
|
||||
@@ -153,20 +153,20 @@ public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashabl
|
||||
|
||||
public struct OpenClawChatSessionsListResponse: Codable, Sendable {
|
||||
public let ts: Double?
|
||||
public let databasePath: String?
|
||||
public let path: String?
|
||||
public let count: Int?
|
||||
public let defaults: OpenClawChatSessionsDefaults?
|
||||
public let sessions: [OpenClawChatSessionEntry]
|
||||
|
||||
public init(
|
||||
ts: Double?,
|
||||
databasePath: String?,
|
||||
path: String?,
|
||||
count: Int?,
|
||||
defaults: OpenClawChatSessionsDefaults?,
|
||||
sessions: [OpenClawChatSessionEntry])
|
||||
{
|
||||
self.ts = ts
|
||||
self.databasePath = databasePath
|
||||
self.path = path
|
||||
self.count = count
|
||||
self.defaults = defaults
|
||||
self.sessions = sessions
|
||||
|
||||
@@ -14,12 +14,19 @@ public struct DeviceAuthEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeviceAuthStoreFile: Codable {
|
||||
var version: Int
|
||||
var deviceId: String
|
||||
var tokens: [String: DeviceAuthEntry]
|
||||
}
|
||||
|
||||
public enum DeviceAuthStore {
|
||||
private static let fileName = "device-auth.json"
|
||||
|
||||
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||
let role = self.normalizeRole(role)
|
||||
guard let row = OpenClawSQLiteStateStore.readDeviceAuthToken(deviceId: deviceId, role: role)
|
||||
else { return nil }
|
||||
return self.entry(from: row)
|
||||
return store.tokens[role]
|
||||
}
|
||||
|
||||
public static func storeToken(
|
||||
@@ -29,27 +36,31 @@ 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))
|
||||
do {
|
||||
if let currentDeviceId = OpenClawSQLiteStateStore.readLatestDeviceAuthDeviceId(),
|
||||
currentDeviceId != deviceId
|
||||
{
|
||||
try OpenClawSQLiteStateStore.deleteAllDeviceAuthTokens()
|
||||
if next == nil {
|
||||
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||
}
|
||||
try OpenClawSQLiteStateStore.upsertDeviceAuthToken(self.row(deviceId: deviceId, entry: entry))
|
||||
} catch {
|
||||
// best-effort only
|
||||
next?.tokens[normalizedRole] = entry
|
||||
if let store = next {
|
||||
self.writeStore(store)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
public static func clearToken(deviceId: String, role: String) {
|
||||
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||
let normalizedRole = self.normalizeRole(role)
|
||||
try? OpenClawSQLiteStateStore.deleteDeviceAuthToken(deviceId: deviceId, role: normalizedRole)
|
||||
guard store.tokens[normalizedRole] != nil else { return }
|
||||
store.tokens.removeValue(forKey: normalizedRole)
|
||||
self.writeStore(store)
|
||||
}
|
||||
|
||||
private static func normalizeRole(_ role: String) -> String {
|
||||
@@ -63,34 +74,33 @@ public enum DeviceAuthStore {
|
||||
return Array(Set(trimmed)).sorted()
|
||||
}
|
||||
|
||||
private static func entry(from row: OpenClawSQLiteDeviceAuthTokenRow) -> DeviceAuthEntry {
|
||||
DeviceAuthEntry(
|
||||
token: row.token,
|
||||
role: row.role,
|
||||
scopes: self.decodeScopes(row.scopesJSON),
|
||||
updatedAtMs: row.updatedAtMs)
|
||||
private static func fileURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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 [] }
|
||||
guard decoded.version == 1 else { return nil }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||
let url = self.fileURL()
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(store)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,17 +17,8 @@ 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)
|
||||
@@ -37,13 +28,16 @@ enum DeviceIdentityPaths {
|
||||
}
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
||||
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeviceIdentityStore {
|
||||
private static let identityKey = "default"
|
||||
private static let fileName = "device.json"
|
||||
private static let ed25519SPKIPrefix = Data([
|
||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
|
||||
0x70, 0x03, 0x21, 0x00,
|
||||
@@ -54,49 +48,41 @@ public enum DeviceIdentityStore {
|
||||
])
|
||||
|
||||
public static func loadOrCreate() -> DeviceIdentity {
|
||||
if let row = OpenClawSQLiteStateStore.readDeviceIdentity(key: self.identityKey) {
|
||||
switch self.decodeStoredIdentity(self.storedIdentity(from: row)) {
|
||||
self.loadOrCreate(fileURL: self.fileURL())
|
||||
}
|
||||
|
||||
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
|
||||
if let data = try? Data(contentsOf: url) {
|
||||
switch self.decodeStoredIdentity(data) {
|
||||
case .identity(let decoded):
|
||||
return decoded
|
||||
case .recognizedInvalid:
|
||||
preconditionFailure("Stored OpenClaw device identity is invalid. Run openclaw doctor --fix.")
|
||||
return self.generate()
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
if self.legacyIdentityMigrationRequired() {
|
||||
preconditionFailure(
|
||||
"Legacy OpenClaw device identity exists at \(self.legacyIdentityURL().path). " +
|
||||
"Run openclaw doctor --fix before starting runtime.")
|
||||
}
|
||||
let identity = self.generate()
|
||||
self.save(identity)
|
||||
self.save(identity, to: url)
|
||||
return identity
|
||||
}
|
||||
|
||||
static func legacyIdentityMigrationRequired() -> Bool {
|
||||
FileManager.default.fileExists(atPath: self.legacyIdentityURL().path)
|
||||
}
|
||||
|
||||
private static func legacyIdentityURL() -> URL {
|
||||
DeviceIdentityPaths.stateDirURL()
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device.json", isDirectory: false)
|
||||
}
|
||||
|
||||
private enum DecodeResult {
|
||||
case identity(DeviceIdentity)
|
||||
case recognizedInvalid
|
||||
case unknown
|
||||
}
|
||||
|
||||
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(_ 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)
|
||||
}
|
||||
|
||||
private static func decodeStoredIdentity(_ decoded: StoredDeviceIdentity) -> DecodeResult {
|
||||
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),
|
||||
@@ -111,6 +97,9 @@ public enum DeviceIdentityStore {
|
||||
createdAtMs: decoded.createdAtMs))
|
||||
}
|
||||
|
||||
return self.hasRecognizedIdentityShape(data) ? .recognizedInvalid : .unknown
|
||||
}
|
||||
|
||||
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
|
||||
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
|
||||
do {
|
||||
@@ -148,6 +137,22 @@ 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,
|
||||
@@ -180,51 +185,41 @@ public enum DeviceIdentityStore {
|
||||
return Data(base64Encoded: body)
|
||||
}
|
||||
|
||||
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()
|
||||
private static func hasRecognizedIdentityShape(_ data: Data) -> Bool {
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return false
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
return "-----BEGIN \(label)-----\n\(chunks)\n-----END \(label)-----\n"
|
||||
return object.keys.contains("publicKeyPem")
|
||||
|| object.keys.contains("privateKeyPem")
|
||||
|| object.keys.contains("publicKey")
|
||||
|| object.keys.contains("privateKey")
|
||||
}
|
||||
|
||||
private static func deviceId(publicKeyData: Data) -> String {
|
||||
SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func save(_ identity: DeviceIdentity) {
|
||||
private static func save(_ identity: DeviceIdentity, to url: URL) {
|
||||
do {
|
||||
let stored = self.storedIdentity(from: identity)
|
||||
try OpenClawSQLiteStateStore.writeDeviceIdentity(
|
||||
key: self.identityKey,
|
||||
identity: OpenClawSQLiteDeviceIdentityRow(
|
||||
deviceId: stored.deviceId,
|
||||
publicKeyPem: stored.publicKeyPem,
|
||||
privateKeyPem: stored.privateKeyPem,
|
||||
createdAtMs: stored.createdAtMs))
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(identity)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
preconditionFailure("Failed to persist OpenClaw device identity in SQLite: \(error)")
|
||||
// best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
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 static func fileURL() -> URL {
|
||||
let base = DeviceIdentityPaths.stateDirURL()
|
||||
return base
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent(self.fileName, isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredDeviceIdentity: Codable {
|
||||
private struct PemDeviceIdentity: Codable {
|
||||
var version: Int
|
||||
var deviceId: String
|
||||
var publicKeyPem: String
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
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 {
|
||||
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")
|
||||
} catch {
|
||||
self.logger.warning("SQLite device identity read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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 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")
|
||||
}
|
||||
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)")
|
||||
}
|
||||
|
||||
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 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,7 +751,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let internalruntimehandoffid: String?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let initialvfsentries: [[String: AnyCodable]]?
|
||||
public let voicewaketrigger: String?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
@@ -789,7 +788,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
internalruntimehandoffid: String?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
initialvfsentries: [[String: AnyCodable]]?,
|
||||
voicewaketrigger: String?,
|
||||
idempotencykey: String,
|
||||
label: String?)
|
||||
@@ -826,7 +824,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.internalruntimehandoffid = internalruntimehandoffid
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.initialvfsentries = initialvfsentries
|
||||
self.voicewaketrigger = voicewaketrigger
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
@@ -865,7 +862,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
case internalruntimehandoffid = "internalRuntimeHandoffId"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case initialvfsentries = "initialVfsEntries"
|
||||
case voicewaketrigger = "voiceWakeTrigger"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
@@ -1565,12 +1561,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(
|
||||
@@ -1578,24 +1574,24 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
configuredagentsonly: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?,
|
||||
configuredagentsonly: Bool?,
|
||||
search: String?)
|
||||
{
|
||||
self.limit = limit
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1604,16 +1600,50 @@ 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?
|
||||
@@ -2222,18 +2252,22 @@ public struct SessionsResetParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsDeleteParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let deletetranscript: Bool?
|
||||
public let emitlifecyclehooks: Bool?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
deletetranscript: Bool?,
|
||||
emitlifecyclehooks: Bool?)
|
||||
{
|
||||
self.key = key
|
||||
self.deletetranscript = deletetranscript
|
||||
self.emitlifecyclehooks = emitlifecyclehooks
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case deletetranscript = "deleteTranscript"
|
||||
case emitlifecyclehooks = "emitLifecycleHooks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||
}
|
||||
return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse(
|
||||
ts: nil,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 0,
|
||||
defaults: nil,
|
||||
sessions: [])
|
||||
@@ -829,7 +829,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 4,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -853,7 +853,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -878,7 +878,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload(sessionKey: "Luke’s MacBook Pro", sessionId: "sess-main")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
model: nil,
|
||||
@@ -926,7 +926,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
model: nil,
|
||||
@@ -1155,7 +1155,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
|
||||
sessions: [
|
||||
@@ -1183,7 +1183,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(model: "openai/gpt-4.1-mini", contextTokens: nil),
|
||||
sessions: [
|
||||
@@ -1216,7 +1216,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(model: "openrouter/gpt-4.1-mini", contextTokens: nil),
|
||||
sessions: [
|
||||
@@ -1249,7 +1249,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1282,7 +1282,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1325,7 +1325,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1378,7 +1378,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1428,7 +1428,7 @@ extension TestChatTransportState {
|
||||
let history = historyPayload()
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1476,7 +1476,7 @@ extension TestChatTransportState {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1521,7 +1521,7 @@ extension TestChatTransportState {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let initialSessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1530,7 +1530,7 @@ extension TestChatTransportState {
|
||||
])
|
||||
let sessionsAfterOtherSelection = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1688,7 +1688,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "adaptive")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "openai-codex",
|
||||
@@ -1751,7 +1751,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "xhigh")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
@@ -1799,7 +1799,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "adaptive")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "anthropic",
|
||||
@@ -1855,7 +1855,7 @@ extension TestChatTransportState {
|
||||
thinkingLevel: "max")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: 1,
|
||||
databasePath: nil,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "anthropic",
|
||||
|
||||
@@ -5,126 +5,68 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceIdentityStoreTests {
|
||||
@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()
|
||||
|
||||
#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))
|
||||
|
||||
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("loads TypeScript PEM identity schema from SQLite")
|
||||
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
|
||||
func loadsTypeScriptPEMIdentitySchema() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
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",
|
||||
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)))
|
||||
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
let before = try String(contentsOf: identityURL, encoding: .utf8)
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||
|
||||
#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)))
|
||||
}
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
}
|
||||
|
||||
@Test("requires doctor migration when legacy identity exists before SQLite row")
|
||||
func requiresDoctorMigrationForLegacyIdentity() throws {
|
||||
try Self.withTempStateDir { stateDir in
|
||||
let legacyURL = Self.legacyIdentityURL(stateDir: stateDir)
|
||||
try FileManager.default.createDirectory(
|
||||
at: legacyURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try "{}".write(to: legacyURL, atomically: true, encoding: .utf8)
|
||||
|
||||
#expect(DeviceIdentityStore.legacyIdentityMigrationRequired())
|
||||
#expect(!FileManager.default.fileExists(atPath: Self.databaseURL(stateDir: stateDir).path))
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
private static func withTempStateDir(_ body: (URL) throws -> Void) throws {
|
||||
let previous = DeviceIdentityPaths.testingStateDirURL
|
||||
@Test("does not overwrite a recognized invalid TypeScript identity schema")
|
||||
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
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 databaseURL(stateDir: URL) -> URL {
|
||||
stateDir
|
||||
.appendingPathComponent("state", isDirectory: true)
|
||||
.appendingPathComponent("openclaw.sqlite")
|
||||
}
|
||||
|
||||
private static func legacyIdentityURL(stateDir: URL) -> URL {
|
||||
stateDir
|
||||
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 = """
|
||||
{
|
||||
"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)
|
||||
|
||||
private static func legacyAuthURL(stateDir: URL) -> URL {
|
||||
stateDir
|
||||
.appendingPathComponent("identity", isDirectory: true)
|
||||
.appendingPathComponent("device-auth.json", isDirectory: false)
|
||||
let identity = DeviceIdentityStore.loadOrCreate(fileURL: identityURL)
|
||||
|
||||
#expect(identity.deviceId != "stale-device-id")
|
||||
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
|
||||
}
|
||||
|
||||
private static func base64UrlDecode(_ value: String) -> Data? {
|
||||
@@ -135,7 +77,7 @@ struct DeviceIdentityStoreTests {
|
||||
return Data(base64Encoded: padded)
|
||||
}
|
||||
|
||||
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> Data {
|
||||
private static func identityJSON(publicKeyPem: String, privateKeyPem: String) throws -> String {
|
||||
let object: [String: Any] = [
|
||||
"version": 1,
|
||||
"deviceId": "stale-device-id",
|
||||
@@ -143,14 +85,11 @@ struct DeviceIdentityStoreTests {
|
||||
"privateKeyPem": privateKeyPem,
|
||||
"createdAtMs": 1_700_000_000_000,
|
||||
]
|
||||
return try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
return String(decoding: data, as: UTF8.self) + "\n"
|
||||
}
|
||||
|
||||
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")-----"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,6 @@ 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",
|
||||
@@ -46,7 +43,6 @@ const bundledPluginIgnoredRuntimeDependencies = [
|
||||
"@pierre/theme",
|
||||
"@tloncorp/tlon-skill",
|
||||
"@zed-industries/codex-acp",
|
||||
"audio-decode",
|
||||
"jiti",
|
||||
"json5",
|
||||
"lit",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
b81d0ebea1be6724db490eb4d7ccf37b11c300ec188ceb0a1e47b43b7458f1fd config-baseline.json
|
||||
8950507daef19d672dd97f782ada387ac68aa1d0133cc8fce27a707ed56794f4 config-baseline.core.json
|
||||
0158f00daf99885696ec87523af92ff66d4f7ff43448a49fed24b293a1f48df3 config-baseline.channel.json
|
||||
61af209ebfe24d4ede4740251ffba3f67296ec492779542fb7012e72729b9c0c config-baseline.plugin.json
|
||||
f95819d93e9bec5d059440ab54fb4ccb487425cb91d647c8688cd18ef1d4d848 config-baseline.json
|
||||
3325af3a6292959bb38166e9136c638dce5d2093d2339076742890848088a972 config-baseline.core.json
|
||||
ad1d3cb596115d66c21e93de95e229c14c585f0dd4799b4ae3cc29b84761adc6 config-baseline.channel.json
|
||||
0dac8944a0d51ae96f97e3809907f8a04d08413434a1a1190240f7e13bb11c4d config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
bf42f9c44ddfebc0b9d13090ac610d09d9d41a84dd9256c7c74c5e8faea9259a plugin-sdk-api-baseline.json
|
||||
1df2a71746d5cd71b809c483d5f6ee7ac84e121e4610c9b056bb177c77e1095b plugin-sdk-api-baseline.jsonl
|
||||
542dc30fe44a16119ee57f9fe48a5744beb7fc2cf425a5777b4c4b8b2ce883e1 plugin-sdk-api-baseline.json
|
||||
9f4fde0de9773af635862ea15ce1a3391ef15e3165ad43b2050b1c4b3113acf4 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
"target": "消息生命周期重构"
|
||||
},
|
||||
{
|
||||
"source": "Refactoring",
|
||||
"target": "重构"
|
||||
"source": "ACP lifecycle refactor",
|
||||
"target": "ACP 生命周期重构"
|
||||
},
|
||||
{
|
||||
"source": "Channel message API",
|
||||
@@ -123,14 +123,6 @@
|
||||
"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"
|
||||
@@ -958,13 +950,5 @@
|
||||
{
|
||||
"source": "ACP agents setup",
|
||||
"target": "ACP Agents 设置"
|
||||
},
|
||||
{
|
||||
"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 SQLite auth-profile row.
|
||||
copying secret material into its own `auth-profiles.json`.
|
||||
|
||||
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 the built-in Amazon Bedrock default
|
||||
AWS SDK route. These profile ids may appear in `auth.order` and session
|
||||
overrides even when no matching entry exists in the SQLite auth-profile row.
|
||||
overrides even when no matching entry exists in `auth-profiles.json`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Explicit auth order filtering
|
||||
|
||||
@@ -86,8 +86,8 @@ and removes the marker from the credential store.
|
||||
|
||||
## Probe target resolution
|
||||
|
||||
- Probe targets can come from auth profiles, environment credentials, or the
|
||||
stored model catalog.
|
||||
- Probe targets can come from auth profiles, environment credentials, or
|
||||
`models.json`.
|
||||
- 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`.
|
||||
|
||||
@@ -41,9 +41,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 and runtime execution state persist in the shared SQLite state database at `~/.openclaw/state/openclaw.sqlite`.
|
||||
- Legacy `jobs.json` and `jobs-state.json` files are imported and removed by `openclaw doctor --fix`.
|
||||
- The optional `cron.store` path is now a legacy import namespace and display hint, not a runtime JSON writer.
|
||||
- Job definitions persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules.
|
||||
- Runtime execution state persists next to it in `~/.openclaw/cron/jobs-state.json`. If you track cron definitions in git, track `jobs.json` and gitignore `jobs-state.json`.
|
||||
- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`.
|
||||
- When `jobs.json` is edited while the Gateway is running or stopped, OpenClaw compares the changed schedule fields with pending runtime slot metadata and clears stale `nextRunAtMs` values. Pure formatting or key-order-only rewrites preserve the pending slot.
|
||||
- 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 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.
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## Schedule types
|
||||
@@ -403,7 +404,7 @@ Model override note:
|
||||
{
|
||||
cron: {
|
||||
enabled: true,
|
||||
store: "~/.openclaw/cron/jobs.json", // optional legacy import key
|
||||
store: "~/.openclaw/cron/jobs.json",
|
||||
maxConcurrentRuns: 1,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
@@ -411,6 +412,7 @@ Model override note:
|
||||
retryOn: ["rate_limit", "overloaded", "network", "server_error"],
|
||||
},
|
||||
webhookToken: "replace-with-dedicated-webhook-token",
|
||||
sessionRetention: "24h",
|
||||
runLog: { maxBytes: "2mb", keepLines: 2000 },
|
||||
},
|
||||
}
|
||||
@@ -418,9 +420,9 @@ Model override note:
|
||||
|
||||
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution. 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 data is keyed by the resolved `cron.store` value inside the shared SQLite state database. That value is a legacy import key, not a runtime JSON write path. SQLite stores job definitions, pending slots, active markers, last-run metadata, and the schedule identity used to invalidate stale pending slots after a job update.
|
||||
The runtime state sidecar is derived from `cron.store`: a `.json` store such as `~/clawd/cron/jobs.json` uses `~/clawd/cron/jobs-state.json`, while a store path without a `.json` suffix appends `-state.json`.
|
||||
|
||||
Run `openclaw doctor --fix` once after upgrading from an older version so doctor can import and remove legacy `jobs.json` and `jobs-state.json` files.
|
||||
If you hand-edit `jobs.json`, leave `jobs-state.json` out of source control. OpenClaw uses that sidecar for pending slots, active markers, last-run metadata, and the schedule identity that tells the scheduler when an externally edited job needs a fresh `nextRunAtMs`.
|
||||
|
||||
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
@@ -432,7 +434,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Maintenance">
|
||||
`cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune SQLite run-log rows. Session rows are SQLite-backed and are not age/count-pruned.
|
||||
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune run-log files.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -471,7 +473,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 SQLite transcript session header after doctor migration. 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 transcript JSONL session header when the file is still available. 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, 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, legacy handler, or extra hook directory.
|
||||
|
||||
There are two kinds of hooks in OpenClaw:
|
||||
|
||||
@@ -148,7 +148,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` and extra hook directories 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`, extra hook directories, and legacy handlers opt into broad discovery.
|
||||
|
||||
### Hook packs
|
||||
|
||||
@@ -166,7 +166,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 the shared SQLite state database |
|
||||
| command-logger | `command` | Logs all commands to `~/.openclaw/logs/commands.log` |
|
||||
| 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 |
|
||||
|
||||
@@ -207,8 +207,7 @@ Paths resolve relative to workspace. Only recognized bootstrap basenames are loa
|
||||
|
||||
### command-logger details
|
||||
|
||||
Logs every slash command to the `command_log_entries` table in
|
||||
`~/.openclaw/state/openclaw.sqlite`.
|
||||
Logs every slash command to `~/.openclaw/logs/commands.log`.
|
||||
|
||||
<a id="compaction-notifier"></a>
|
||||
|
||||
@@ -279,7 +278,7 @@ Extra hook directories:
|
||||
```
|
||||
|
||||
<Note>
|
||||
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.
|
||||
The legacy `hooks.internal.handlers` array config format is still supported for backwards compatibility, but new hooks should use the discovery-based system.
|
||||
</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 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.
|
||||
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.
|
||||
|
||||
## 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 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.
|
||||
- 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.
|
||||
|
||||
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/state/openclaw.sqlite
|
||||
$OPENCLAW_STATE_DIR/tasks/runs.sqlite
|
||||
```
|
||||
|
||||
The registry loads into memory at gateway start and syncs writes to SQLite for durability across restarts.
|
||||
@@ -346,7 +346,7 @@ A sweeper runs every **60 seconds** and handles four things:
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Tasks and cron">
|
||||
A cron job **definition** and runtime execution state live in the shared SQLite state database. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
|
||||
A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
|
||||
|
||||
See [Cron Jobs](/automation/cron-jobs).
|
||||
|
||||
|
||||
@@ -128,19 +128,17 @@ Example:
|
||||
|
||||
## Session storage
|
||||
|
||||
Canonical session metadata lives in SQLite:
|
||||
Session stores live under the state directory (default `~/.openclaw`):
|
||||
|
||||
- `~/.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.
|
||||
- `~/.openclaw/agents/<agentId>/sessions/sessions.json`
|
||||
- JSONL transcripts live alongside the store
|
||||
|
||||
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.
|
||||
You can override the store path via `session.store` and `{agentId}` templating.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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 assistant final replies stay private by default. Visible Discord output must be sent explicitly with the `message` tool, so the agent can lurk by default and only post when it decides a channel reply is useful.
|
||||
|
||||
This means the selected model must reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check the SQLite transcript for assistant text with `didSendViaMessagingTool: false`. That means the model produced a private final answer instead of calling `message(action=send)`. Switch to a stronger tool-calling model, or use the config below to restore legacy automatic final replies.
|
||||
This means the selected model must reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check the session log for assistant text with `didSendViaMessagingTool: false`. That means the model produced a private final answer instead of calling `message(action=send)`. Switch to a stronger tool-calling model, or use the config below to restore legacy automatic final replies.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Ask your agent">
|
||||
|
||||
@@ -85,7 +85,7 @@ 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 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.
|
||||
- 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 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
|
||||
|
||||
@@ -274,7 +274,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 (stored in SQLite pairing state) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- DM pairing approvals (`*-allowFrom` store entries) apply to DM access only; group sender authorization stays explicit to group allowlists.
|
||||
- Discord: allowlist uses `channels.discord.guilds.<id>.channels`.
|
||||
- Slack: allowlist uses `channels.slack.channels`.
|
||||
- Matrix: allowlist uses `channels.matrix.groups`. 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.
|
||||
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -20,23 +20,21 @@ You do not need to rename config keys or reinstall the plugin under a new name.
|
||||
|
||||
## What the migration does 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.
|
||||
When the gateway starts, and when you run [`openclaw doctor --fix`](/gateway/doctor), OpenClaw tries to repair old Matrix state automatically.
|
||||
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, 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
|
||||
- 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
|
||||
|
||||
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
|
||||
- importing old Matrix sync stores into SQLite plugin state
|
||||
- importing old Matrix IndexedDB crypto snapshots into SQLite plugin blobs
|
||||
- moving the oldest flat Matrix sync store into the current account-scoped location
|
||||
- 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
|
||||
@@ -45,7 +43,7 @@ Automatic migration covers:
|
||||
|
||||
Snapshot details:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -71,14 +69,14 @@ OpenClaw cannot automatically recover:
|
||||
|
||||
Current warning scope:
|
||||
|
||||
- custom Matrix plugin path installs are surfaced by `openclaw doctor`
|
||||
- custom Matrix plugin path installs are surfaced by both gateway startup and `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` so the update flow runs doctor before the gateway restarts.
|
||||
Prefer plain `openclaw update` without `--no-restart` so startup can finish the Matrix migration immediately.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
@@ -138,8 +136,8 @@ If your old installation had local-only encrypted history that was never backed
|
||||
|
||||
Encrypted migration is a two-stage process:
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -167,7 +165,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`.
|
||||
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`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...).`
|
||||
|
||||
@@ -177,12 +175,22 @@ 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`.
|
||||
- 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`.
|
||||
|
||||
`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`.
|
||||
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`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...).`
|
||||
|
||||
@@ -192,29 +200,34 @@ 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`.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`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`.
|
||||
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`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`.
|
||||
- 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.
|
||||
|
||||
`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`.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`- 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`.
|
||||
- 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.
|
||||
|
||||
`Matrix is installed from a custom path: ...`
|
||||
|
||||
|
||||
@@ -480,9 +480,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. OpenClaw persists the IndexedDB crypto snapshot into SQLite plugin blobs; older `crypto-idb-snapshot.json` files are imported by `openclaw doctor --fix`.
|
||||
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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -861,9 +861,9 @@ Uploaded files are stored in a `/OpenClawShared/` folder in the configured Share
|
||||
OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API).
|
||||
|
||||
- CLI: `openclaw message poll --channel msteams --target conversation:<id> ...`
|
||||
- Votes are recorded by the gateway in the shared SQLite plugin state store.
|
||||
- Votes are recorded by the gateway in `~/.openclaw/msteams-polls.json`.
|
||||
- The gateway must stay online to record votes.
|
||||
- Polls do not auto-post result summaries yet.
|
||||
- Polls do not auto-post result summaries yet (inspect the store file if needed).
|
||||
|
||||
## Presentation cards
|
||||
|
||||
|
||||
@@ -78,20 +78,17 @@ Access groups are documented in detail here: [Access groups](/channels/access-gr
|
||||
|
||||
### Where the state lives
|
||||
|
||||
Stored in `~/.openclaw/state/openclaw.sqlite`:
|
||||
Stored under `~/.openclaw/credentials/`:
|
||||
|
||||
- Pending requests: `channel_pairing_requests`
|
||||
- Approved allowlist entries: `channel_pairing_allow_entries`, account-scoped by channel account ID
|
||||
- Pending requests: `<channel>-pairing.json`
|
||||
- Approved allowlist store:
|
||||
- Default account: `<channel>-allowFrom.json`
|
||||
- Non-default account: `<channel>-<accountId>-allowFrom.json`
|
||||
|
||||
Account scoping behavior:
|
||||
|
||||
- Non-default accounts read/write only their scoped allowlist 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.
|
||||
- Non-default accounts read/write only their scoped allowlist file.
|
||||
- Default account uses the channel-scoped unscoped allowlist file.
|
||||
|
||||
Treat these as sensitive (they gate access to your assistant).
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ Token resolution order is account-aware. In practice, config values win over env
|
||||
`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 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.
|
||||
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).
|
||||
|
||||
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).
|
||||
|
||||
@@ -699,9 +699,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `Sticker.fileUniqueId`
|
||||
- `Sticker.cachedDescription`
|
||||
|
||||
Sticker cache storage:
|
||||
Sticker cache file:
|
||||
|
||||
- SQLite plugin state in `~/.openclaw/state/openclaw.sqlite`
|
||||
- `~/.openclaw/telegram/sticker-cache.json`
|
||||
|
||||
Stickers are described once (when possible) and cached to reduce repeated vision calls.
|
||||
|
||||
@@ -826,7 +826,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 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.
|
||||
- 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.
|
||||
- Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary.
|
||||
- DM history controls:
|
||||
- `channels.telegram.dmHistoryLimit`
|
||||
@@ -960,7 +960,7 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
|
||||
|
||||
<Accordion title="Polling or network instability">
|
||||
|
||||
- Node 24+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch.
|
||||
- Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch.
|
||||
- Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures.
|
||||
- If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors.
|
||||
- During polling startup, OpenClaw reuses the successful startup `getMe` probe for grammY so the runner does not need a second `getMe` before the first `getUpdates`.
|
||||
@@ -979,7 +979,7 @@ channels:
|
||||
proxy: socks5://<user>:<password>@proxy-host:1080
|
||||
```
|
||||
|
||||
- Node 24+ defaults to `autoSelectFamily=true` (except WSL2). Telegram DNS result order honors `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`, then `channels.telegram.network.dnsResultOrder`, then the process default such as `NODE_OPTIONS=--dns-result-order=ipv4first`; if none applies, Node 24+ falls back to `ipv4first`.
|
||||
- Node 22+ defaults to `autoSelectFamily=true` (except WSL2). Telegram DNS result order honors `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`, then `channels.telegram.network.dnsResultOrder`, then the process default such as `NODE_OPTIONS=--dns-result-order=ipv4first`; if none applies, Node 22+ falls back to `ipv4first`.
|
||||
- If your host is WSL2 or explicitly works better with IPv4-only behavior, force family selection:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -223,7 +223,7 @@ content and identifiers.
|
||||
|
||||
Runtime behavior details:
|
||||
|
||||
- pairings are persisted in SQLite channel pairing state and merged with configured `allowFrom`
|
||||
- pairings are persisted in channel allow-store 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)
|
||||
|
||||
@@ -27,6 +27,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
|
||||
| `check-additional` | Architecture, sharded boundary/prompt drift, extension guards, package boundary, and gateway watch | Node-relevant changes |
|
||||
| `build-smoke` | Built-CLI smoke tests and startup-memory smoke | Node-relevant changes |
|
||||
| `checks` | Verifier for built-artifact channel tests | Node-relevant changes |
|
||||
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
|
||||
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
|
||||
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
|
||||
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
|
||||
@@ -52,7 +53,7 @@ The `ci-timings-summary` job uploads a compact `ci-timings-summary` artifact for
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
|
||||
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
|
||||
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
|
||||
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
|
||||
|
||||
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: channel contracts run as three weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, cron, and shared shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped across four matrix shards, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
|
||||
@@ -80,7 +81,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
|
||||
|
||||
## Manual dispatches
|
||||
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, channel contracts, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, channel contracts, Node 22 compatibility, `check`, `check-additional`, build smoke, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
|
||||
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref.
|
||||
|
||||
@@ -95,7 +96,7 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
| Runner | Jobs |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ubuntu-24.04` | `preflight`, fast security jobs and aggregates (`security-scm-fast`, `security-dependency-audit`, `security-fast`), fast protocol/contract/bundled checks, sharded channel contract checks, `check` shards except lint, `check-additional` aggregates, Node test aggregate verifiers, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, lower-weight extension shards, `checks-fast-core`, `check-prod-types`, and `check-test-types` |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, lower-weight extension shards, `checks-fast-core`, `checks-node-compat-node22`, `check-prod-types`, and `check-test-types` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | build-smoke, Linux Node test shards, bundled plugin test shards, `check-additional` shards, `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
|
||||
|
||||
@@ -60,7 +60,7 @@ openclaw agent --agent ops --message "Run locally" --local
|
||||
- `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly.
|
||||
- Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs.
|
||||
- If the Gateway accepts an agent run but the CLI times out waiting for the final reply, embedded fallback uses a fresh explicit `gateway-fallback-*` session/run id and reports `meta.fallbackReason: "gateway_timeout"` plus the fallback session fields. This avoids racing the Gateway-owned transcript lock or silently replacing the original routed conversation session.
|
||||
- When this command materializes the stored model catalog, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
|
||||
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
|
||||
- Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values.
|
||||
|
||||
## JSON delivery status
|
||||
|
||||
@@ -151,8 +151,7 @@ Notes:
|
||||
|
||||
- `main` cannot be deleted.
|
||||
- Without `--force`, interactive confirmation is required.
|
||||
- Workspace and per-agent state directories are moved to Trash, not hard-deleted.
|
||||
- Session rows for the deleted agent are purged from SQLite.
|
||||
- Workspace, agent state, and session transcript directories are moved to Trash, not hard-deleted.
|
||||
- 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 state in SQLite. Use `--gateway` to target the gateway, or `--node` to target a specific node.
|
||||
By default, commands target the local approvals file on disk. 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 state aligned in one step.
|
||||
`tools.exec.*` config and the local host approvals file aligned in one step.
|
||||
|
||||
Use it when you want to:
|
||||
|
||||
- inspect the local requested policy, host approvals state, and effective merge
|
||||
- inspect the local requested policy, host approvals file, and effective merge
|
||||
- apply a local preset such as YOLO or deny-all
|
||||
- synchronize local `tools.exec.*` and local exec approvals state
|
||||
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -49,10 +49,10 @@ Output modes:
|
||||
Current scope:
|
||||
|
||||
- `exec-policy` is **local-only**
|
||||
- it updates the local config file and the local approvals state together
|
||||
- it updates the local config file and the local approvals file 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 local approvals state
|
||||
- `openclaw exec-policy show` marks `host=node` scopes as node-managed at runtime instead of deriving an effective policy from the local approvals file
|
||||
|
||||
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 state is the enforceable source of truth
|
||||
- the host approvals file 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 state with gateway `tools.exec` policy, because both still apply at runtime
|
||||
- `--node` combines the node host approvals file 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 state** only. To keep the requested OpenClaw policy aligned, also set:
|
||||
This changes the **host approvals file** 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 state
|
||||
- `--gateway` targets the gateway host approvals state
|
||||
- no target flags means the local approvals file on disk
|
||||
- `--gateway` targets the gateway host approvals file
|
||||
- `--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 are stored per host in the SQLite state database. Legacy `~/.openclaw/exec-approvals.json` files are imported by `openclaw doctor --fix`.
|
||||
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw backup` (create, verify, and restore local backup archives)"
|
||||
summary: "CLI reference for `openclaw backup` (create 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,33 +8,27 @@ title: "Backup"
|
||||
|
||||
# `openclaw backup`
|
||||
|
||||
Create, verify, or restore a local backup archive for OpenClaw state, config,
|
||||
channel/provider credentials, sessions, auth profiles, and optionally
|
||||
workspaces.
|
||||
Create a local backup archive for OpenClaw state, config, auth profiles, channel/provider credentials, sessions, and optionally workspaces.
|
||||
|
||||
```bash
|
||||
openclaw backup create
|
||||
openclaw backup create --output ~/Backups
|
||||
openclaw backup create --dry-run --json
|
||||
openclaw backup create --no-verify
|
||||
openclaw backup create --verify
|
||||
openclaw backup create --no-include-workspace
|
||||
openclaw backup create --only-config
|
||||
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.
|
||||
- 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 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 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 --only-config` backs up just the active JSON config file.
|
||||
|
||||
## What gets backed up
|
||||
@@ -46,8 +40,9 @@ openclaw backup restore ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz --dry-
|
||||
- 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 stored in SQLite under the state directory, so they are
|
||||
covered by the database snapshots in the state backup entry.
|
||||
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.
|
||||
|
||||
If you use `--only-config`, OpenClaw skips state, credentials-directory, and workspace discovery and archives only the active config file path.
|
||||
|
||||
@@ -90,7 +85,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 after `openclaw backup create`, unless you pass `--no-verify`
|
||||
- Time to rescan the archive if you use `openclaw backup create --verify` or run `openclaw backup 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`.
|
||||
|
||||
@@ -80,7 +80,7 @@ Text output includes:
|
||||
- scope
|
||||
- suggested check-in text
|
||||
|
||||
JSON output also includes the SQLite state database path and full stored records.
|
||||
JSON output also includes the commitment store path and full stored records.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "CLI reference for `openclaw completion` (generate/install shell completion scripts)"
|
||||
read_when:
|
||||
- You want shell completions for zsh/bash/fish/PowerShell
|
||||
- You need to install shell completion profile hooks
|
||||
- You need to cache completion scripts under OpenClaw state
|
||||
title: "Completion"
|
||||
---
|
||||
|
||||
@@ -17,20 +17,22 @@ openclaw completion
|
||||
openclaw completion --shell zsh
|
||||
openclaw completion --install
|
||||
openclaw completion --shell fish --install
|
||||
openclaw completion --write-state
|
||||
openclaw completion --shell bash --write-state
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `-s, --shell <shell>`: shell target (`zsh`, `bash`, `powershell`, `fish`; default: `zsh`)
|
||||
- `-i, --install`: install completion by adding a source line to your shell profile
|
||||
- `--write-state`: write completion script(s) to `$OPENCLAW_STATE_DIR/completions` without printing to stdout
|
||||
- `-y, --yes`: skip install confirmation prompts
|
||||
|
||||
## Notes
|
||||
|
||||
- `--install` writes a small "OpenClaw Completion" block into your shell profile that generates completions from the CLI.
|
||||
- Without `--install`, the command prints the script to stdout.
|
||||
- `--install` writes a small "OpenClaw Completion" block into your shell profile and points it at the cached script.
|
||||
- Without `--install` or `--write-state`, the command prints the script to stdout.
|
||||
- Completion generation eagerly loads command trees so nested subcommands are included.
|
||||
- OpenClaw does not write shell completion cache files under state.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ you pass `--yes` for a direct command:
|
||||
Applied writes are recorded in:
|
||||
|
||||
```text
|
||||
SQLite core plugin state: core:crestodian/audit
|
||||
~/.openclaw/audit/crestodian.jsonl
|
||||
```
|
||||
|
||||
Discovery is not audited. Only applied operations and writes are logged.
|
||||
|
||||
@@ -96,7 +96,7 @@ Skipped runs are tracked separately from execution errors. They do not affect re
|
||||
|
||||
For isolated jobs that target a local configured model provider, cron runs a lightweight provider preflight before starting the agent turn. Loopback, private-network, and `.local` `api: "ollama"` providers are probed at `/api/tags`; local OpenAI-compatible providers such as vLLM, SGLang, and LM Studio are probed at `/models`. If the endpoint is unreachable, the run is recorded as `skipped` and retried on a later schedule; matching dead endpoints are cached for 5 minutes to avoid many jobs hammering the same local server.
|
||||
|
||||
Note: cron job definitions and pending runtime state live in the shared SQLite state database. Legacy `jobs.json` and `jobs-state.json` files are imported and removed by `openclaw doctor --fix`.
|
||||
Note: cron job definitions live in `jobs.json`, while pending runtime state lives in `jobs-state.json`. If `jobs.json` is edited externally, the Gateway reloads changed schedules and clears stale pending slots; formatting-only rewrites do not clear the pending slot.
|
||||
|
||||
### Manual runs
|
||||
|
||||
@@ -156,14 +156,15 @@ Isolated cron runs prefer structured execution-denial metadata from the embedded
|
||||
|
||||
## Retention
|
||||
|
||||
Cron run-log retention is controlled by `cron.runLog.maxBytes` and
|
||||
`cron.runLog.keepLines`. Session rows are SQLite-backed and are not pruned by
|
||||
age/count maintenance.
|
||||
Retention and pruning are controlled in config:
|
||||
|
||||
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
|
||||
- `cron.runLog.maxBytes` and `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
|
||||
|
||||
## Migrating older jobs
|
||||
|
||||
<Note>
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates simple `notify: true` webhook fallback jobs to explicit webhook delivery when the deprecated migration fallback `cron.webhook` is configured.
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates simple `notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is configured.
|
||||
</Note>
|
||||
|
||||
## Common edits
|
||||
|
||||
@@ -52,8 +52,8 @@ Notes:
|
||||
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution.
|
||||
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
|
||||
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
|
||||
- State integrity checks now detect orphan legacy transcript files in old sessions directories. Deleting those leftovers requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place unless a migration step imports and removes them.
|
||||
- Doctor also imports legacy `~/.openclaw/cron/jobs.json` / `jobs-state.json` cron stores into SQLite and normalizes old job shapes before the scheduler sees them.
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment.
|
||||
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
|
||||
- Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, preserves session auth-profile pins such as `openai-codex:...`, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth.
|
||||
@@ -70,15 +70,9 @@ Notes:
|
||||
- Doctor removes retired `plugins.entries.codex.config.codexDynamicToolsProfile`; Codex app-server always keeps Codex-native workspace tools native.
|
||||
- Doctor warns when skills allowed for the default agent are unavailable in the current runtime environment because bins, env vars, config, or OS requirements are missing. `doctor --fix` can disable those unavailable skills with `skills.entries.<skill>.enabled=false`; install/configure the missing requirement instead when you want to keep the skill active.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
- If legacy sandbox registry files (`~/.openclaw/sandbox/containers.json`, `~/.openclaw/sandbox/browsers.json`, or old registry shard JSON files) are present, doctor reports them; `openclaw doctor --fix` migrates valid entries into SQLite and quarantines invalid legacy files.
|
||||
- Legacy session state (`sessions.json`, transcript JSONL files, compaction checkpoints, and related session sidecars) is a doctor/migrate input only. Repair imports valid data into the global/per-agent SQLite databases and removes successfully imported sources; runtime code no longer keeps compatibility readers for those files.
|
||||
- If legacy sandbox registry files (`~/.openclaw/sandbox/containers.json` or `~/.openclaw/sandbox/browsers.json`) are present, doctor reports them; `openclaw doctor --fix` migrates valid entries into sharded registry directories and quarantines invalid legacy files.
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
|
||||
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.
|
||||
- Extension-owned state migrations run through doctor without loading full
|
||||
channel runtimes. BlueBubbles, Discord, Feishu, Matrix, Microsoft Teams,
|
||||
QQBot, and Telegram import their legacy JSON sidecars into SQLite plugin
|
||||
state/blob tables from their own setup/doctor migration files, then remove the
|
||||
imported sources.
|
||||
- After state-directory migrations, doctor warns when enabled default Telegram or Discord accounts depend on env fallback and `TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN` is unavailable to the doctor process.
|
||||
- Telegram `allowFrom` username auto-resolution (`doctor --fix`) requires a resolvable Telegram token in the current command path. If token inspection is unavailable, doctor reports a warning and skips auto-resolution for that pass.
|
||||
|
||||
|
||||
@@ -99,7 +99,10 @@ openclaw gateway run
|
||||
Alias for `--ws-log compact`.
|
||||
</ParamField>
|
||||
<ParamField path="--raw-stream" type="boolean">
|
||||
Log raw model stream events to SQLite diagnostics.
|
||||
Log raw model stream events to jsonl.
|
||||
</ParamField>
|
||||
<ParamField path="--raw-stream-path <path>" type="string">
|
||||
Raw stream jsonl path.
|
||||
</ParamField>
|
||||
|
||||
## Restart the Gateway
|
||||
@@ -122,7 +125,7 @@ Inline `--password` can be exposed in local process listings. Prefer `--password
|
||||
### Startup profiling
|
||||
|
||||
- Set `OPENCLAW_GATEWAY_STARTUP_TRACE=1` to log phase timings during Gateway startup, including per-phase `eventLoopMax` delay and plugin lookup-table timings for installed-index, manifest registry, startup planning, and owner-map work.
|
||||
- Set `OPENCLAW_DIAGNOSTICS=timeline` to write a best-effort startup diagnostics timeline into the shared SQLite state database for external QA harnesses. You can also enable the flag with `diagnostics.flags: ["timeline"]` in config. Add `OPENCLAW_DIAGNOSTICS_EVENT_LOOP=1` to include event-loop samples.
|
||||
- Set `OPENCLAW_DIAGNOSTICS=timeline` with `OPENCLAW_DIAGNOSTICS_TIMELINE_PATH=<path>` to write a best-effort JSONL startup diagnostics timeline for external QA harnesses. You can also enable the flag with `diagnostics.flags: ["timeline"]` in config; the path is still env-provided. Add `OPENCLAW_DIAGNOSTICS_EVENT_LOOP=1` to include event-loop samples.
|
||||
- Run `pnpm test:startup:gateway -- --runs 5 --warmup 1` to benchmark Gateway startup. The benchmark records first process output, `/healthz`, `/readyz`, startup trace timings, event-loop delay, and plugin lookup-table timing details.
|
||||
|
||||
## Query a running Gateway
|
||||
@@ -160,7 +163,7 @@ The HTTP `/healthz` endpoint is a liveness probe: it returns once the server can
|
||||
|
||||
### `gateway usage-cost`
|
||||
|
||||
Fetch usage-cost summaries from session transcripts.
|
||||
Fetch usage-cost summaries from session logs.
|
||||
|
||||
```bash
|
||||
openclaw gateway usage-cost
|
||||
@@ -206,7 +209,7 @@ openclaw gateway stability --json
|
||||
<AccordionGroup>
|
||||
<Accordion title="Privacy and bundle behavior">
|
||||
- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. Set `diagnostics.enabled: false` to disable the recorder entirely.
|
||||
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to the shared SQLite state database when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
|
||||
- On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -300,7 +300,7 @@ openclaw hooks enable bootstrap-extra-files
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to the shared SQLite state database.
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
**Enable:**
|
||||
|
||||
@@ -308,19 +308,19 @@ Logs all command events to the shared SQLite state database.
|
||||
openclaw hooks enable command-logger
|
||||
```
|
||||
|
||||
**Output:** `~/.openclaw/state/openclaw.sqlite`, table `command_log_entries`
|
||||
**Output:** `~/.openclaw/logs/commands.log`
|
||||
|
||||
**View logs:**
|
||||
|
||||
```bash
|
||||
# Recent commands
|
||||
sqlite3 ~/.openclaw/state/openclaw.sqlite 'select datetime(timestamp_ms / 1000, "unixepoch"), action, session_key, sender_id, source from command_log_entries order by timestamp_ms desc limit 20;'
|
||||
tail -n 20 ~/.openclaw/logs/commands.log
|
||||
|
||||
# Pretty-print
|
||||
sqlite3 -json ~/.openclaw/state/openclaw.sqlite 'select entry_json from command_log_entries order by timestamp_ms desc limit 20;' | jq .
|
||||
cat ~/.openclaw/logs/commands.log | jq .
|
||||
|
||||
# Filter by action
|
||||
sqlite3 ~/.openclaw/state/openclaw.sqlite 'select entry_json from command_log_entries where action = "new" order by timestamp_ms desc;'
|
||||
grep '"action":"new"' ~/.openclaw/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/automation/hooks#command-logger)
|
||||
|
||||
@@ -194,6 +194,7 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
status
|
||||
health
|
||||
sessions
|
||||
cleanup
|
||||
tasks
|
||||
list
|
||||
audit
|
||||
|
||||
@@ -53,7 +53,7 @@ openclaw memory index --agent main --verbose
|
||||
|
||||
- `--deep`: probe local vector-store readiness, embedding-provider readiness, and semantic vector-search readiness. Plain `memory status` stays fast and does not run live embedding or provider discovery work; unknown vector-store or semantic-vector state means it was not probed in that command. QMD lexical `searchMode: "search"` skips semantic vector probes and embedding maintenance even with `--deep`.
|
||||
- `--index`: run a reindex if the store is dirty (implies `--deep`).
|
||||
- `--fix`: normalize short-term promotion metadata.
|
||||
- `--fix`: repair stale recall locks and normalize promotion metadata.
|
||||
- `--json`: print JSON output.
|
||||
|
||||
If `memory status` shows `Dreaming status: blocked`, the managed dreaming cron is enabled but the heartbeat that drives it is not firing for the default agent. See [Dreaming never runs](/concepts/dreaming#dreaming-never-runs-status-shows-blocked) for the two common causes.
|
||||
|
||||
@@ -10,10 +10,6 @@ title: "Migrate"
|
||||
|
||||
Import state from another agent system through a plugin-owned migration provider. Bundled providers cover Codex CLI state, [Claude](/install/migrating-claude), and [Hermes](/install/migrating-hermes); third-party plugins can register additional providers.
|
||||
|
||||
Legacy OpenClaw file-to-database imports are doctor-owned. Run
|
||||
`openclaw doctor --fix` after upgrading an older state directory so doctor can
|
||||
create the database and import legacy files in one migration pass.
|
||||
|
||||
<Tip>
|
||||
For user-facing walkthroughs, see [Migrating from Claude](/install/migrating-claude) and [Migrating from Hermes](/install/migrating-hermes). The [migration hub](/install/migrating) lists all paths.
|
||||
</Tip>
|
||||
@@ -201,7 +197,7 @@ For migrated source-installed curated plugins, apply writes:
|
||||
|
||||
- `plugins.entries.codex.enabled: true`
|
||||
- `plugins.entries.codex.config.codexPlugins.enabled: true`
|
||||
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions: false`
|
||||
- `plugins.entries.codex.config.codexPlugins.allow_destructive_actions: true`
|
||||
- one explicit plugin entry with `marketplaceName: "openai-curated"` and
|
||||
`pluginName` for each selected plugin
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user