diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index a610665741e4..d692dad8a080 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2494,7 +2494,6 @@ src/commands/backup-git.ts 1 src/commands/backup-restore.ts 1 src/commands/backup-schedule.ts 2 src/commands/backup-shared.ts 1 -src/commands/backup-verify-manifest.ts 1 src/commands/backup-verify.ts 2 src/commands/channel-setup/channel-plugin-resolution.ts 1 src/commands/channel-setup/config-compatibility.ts 2 @@ -3147,7 +3146,7 @@ src/infra/approval-handler-runtime.ts 15 src/infra/approval-presentation.ts 3 src/infra/backup-archive-publication.ts 6 src/infra/backup-create-stream.ts 3 -src/infra/backup-create.ts 3 +src/infra/backup-create.ts 1 src/infra/backup-tar-retry.ts 4 src/infra/backup-volatile-stat-cache.ts 1 src/infra/bonjour-discovery.ts 5 diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 910349edcf40..f90b2fd6bb46 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -693,7 +693,6 @@ src/gateway/worker-environments/store.ts src/hooks/install.ts src/infra/approval-handler-runtime.ts src/infra/backup-create.test.ts -src/infra/backup-create.ts src/infra/clawhub-install-trust.ts src/infra/command-explainer/extract.ts src/infra/device-pairing.test.ts diff --git a/docs/cli/backup.md b/docs/cli/backup.md index 4052707a9f6d..ff4550b4e8a7 100644 --- a/docs/cli/backup.md +++ b/docs/cli/backup.md @@ -42,7 +42,7 @@ Archive `create`, `verify`, and `restore`, plus SQLite `create`, `list`, `verify ## Notes -- The archive embeds a `manifest.json` with the resolved source paths and archive layout. +- The archive embeds a schema-version-1 `manifest.json` with the resolved source paths and archive layout. Additive ownership metadata records configured agent ids and roots, including agent roots already covered by another asset; existing archive layout and older archives remain supported. - Default output is a timestamped `.tar.gz` archive in the current working directory. Timestamped filenames use your machine's local timezone and include the UTC offset. If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location. - Existing archive files are never overwritten. Output paths inside the source state/workspace trees are rejected to avoid self-inclusion. - `openclaw backup verify ` checks that the archive contains exactly one root manifest, rejects traversal-style archive paths, unsafe symbolic links, and SQLite sidecars, confirms every manifest-declared payload exists, validates every SQLite snapshot's file shape, and runs full integrity and role checks on canonical OpenClaw databases. Dedicated plugin schemas remain opaque because they may require owner-defined SQLite capabilities. `openclaw backup create --verify` runs that validation immediately after writing the archive. @@ -57,12 +57,13 @@ live state directory: openclaw backup restore --target ``` -The target must not exist or must be an empty directory. Restore verifies the -archive and its SQLite databases before creating or writing the target, refuses -a non-empty target, and removes an incomplete extraction if anything fails. It -never restores in place and has no `--force` mode. The extracted layout retains -the archive root, manifest, and `payload/` paths exactly as recorded in the -archive. +The target must not exist or must be an empty directory, and it cannot be inside +the live state directory or any configured live agent directory. Restore +verifies the archive and its SQLite databases before creating or writing the +target, refuses a non-empty target, and removes an incomplete extraction if +anything fails. It never restores in place and has no `--force` mode. The +extracted layout retains the archive root, manifest, and `payload/` paths +exactly as recorded in the archive. Restoring an archive is time travel. Messaging-channel credentials with @@ -78,7 +79,9 @@ archive. Activation is a separate offline operator step. Stop the Gateway, move the restored state asset into place or point `OPENCLAW_STATE_DIR` at that asset, then run `openclaw doctor` before restarting. Use `manifest.json` as the source -of truth for the state, config, credentials, and workspace asset paths. See +of truth for the state, config, credentials, workspace, and configured agent +paths. Restore custom agent roots to the locations configured by `agentDir`, or +update those settings to their new locations before restarting. See [Restore a full archive](/install/backups#restore-a-full-archive) for the full disaster-recovery sequence. @@ -86,7 +89,9 @@ disaster-recovery sequence. Use `openclaw backup sqlite` when you need a portable artifact for one OpenClaw-owned SQLite database instead of a broad state archive. -Snapshot creation accepts exactly one named source: +Snapshot creation accepts exactly one named source. Agent sources always use +the current configuration's resolved `/openclaw-agent.sqlite`, even +when `agentDir` is outside the state directory: | Command | Database | | --------------------------------------------------------------- | ---------------------- | @@ -134,7 +139,8 @@ agents//schema.sql agents//tables/.jsonl ``` -Initialize the repository, then create a snapshot of all registered databases: +Initialize the repository, then create a snapshot of the shared database and +all configured agent databases: ```bash openclaw backup git init --repository ~/Backups/openclaw-git --remote @@ -153,7 +159,7 @@ scope. With `--all`, it validates every existing entry under `agents/` before removing stale backup-owned agent scopes, so an unowned entry aborts the cleanup before anything is deleted. -You can also select `--global`, repeat `--agent `, or combine the shared database with selected agents. Snapshot creation uses the same online backup, sanitizer, `VACUUM`, owner validation, and integrity checks as `backup sqlite create`; it never reads live SQLite files directly. Rows and schema entries have deterministic ordering, and integers and blobs use lossless encodings. The command creates one commit named `openclaw backup `. If the database content is unchanged, it prints `no changes` and creates no commit. +You can also select `--global`, repeat `--agent `, or combine the shared database with selected agents. Explicit agent selections, `--all`, and scheduled backups resolve each database from its configured `agentDir`; historical artifact verification and restore use the artifact's recorded agent id without requiring that agent to remain in the current configuration. Snapshot creation uses the same online backup, sanitizer, `VACUUM`, owner validation, and integrity checks as `backup sqlite create`; it never reads live SQLite files directly. Rows and schema entries have deterministic ordering, and integers and blobs use lossless encodings. The command creates one commit named `openclaw backup `. If the database content is unchanged, it prints `no changes` and creates no commit. Git staging is restricted to the backup-owned `global` and `agents` paths; unrelated files elsewhere in an adopted repository are never staged. @@ -237,13 +243,27 @@ Every real archive, SQLite snapshot, and Git create attempt records a compact ou - The state directory (usually `~/.openclaw`) - The active config file path - The resolved `credentials/` directory when it exists outside the state directory +- Every configured agent directory, including custom `agentDir` roots outside the state directory - Workspace directories discovered from the current config, unless you pass `--no-include-workspace` +- Durable resources declared by effectively activated, loadable plugin manifests -Auth profiles and other per-agent runtime state live in SQLite under the state directory (`agents//agent/openclaw-agent.sqlite`), so they are covered by the state backup entry automatically. +Auth profiles and other per-agent runtime state live in +`/openclaw-agent.sqlite`. The default agent root is +`/agents//agent`, but a custom root remains authoritative +whether it is outside the state directory, inside a workspace, or nested under +an otherwise regenerable managed state root. `--no-include-workspace` omits +ordinary workspace sources, not configured agent directories. -`--only-config` skips state, credentials-directory, and workspace discovery and archives only the active config file path. +`--only-config` skips state, agent, credentials-directory, workspace, and +plugin-resource discovery and archives only the active config file path. -OpenClaw canonicalizes paths before building the archive: if config, the credentials directory, or a workspace already live inside the state directory, they are not duplicated as separate top-level backup sources. Missing paths are skipped. +OpenClaw builds one immutable, configuration-derived ownership inventory before +planning sources, SQLite snapshots, exclusions, results, and the embedded +manifest. Paths are canonicalized: config, credentials, workspaces, and agents +already covered by another included root are not duplicated as top-level +sources. A custom agent root becomes a distinct `agent` asset only when no +existing asset covers it; the manifest still records its agent id and root when +another asset contains it. Missing paths are reported as skipped. During archive creation, OpenClaw excludes known live-mutation paths before `tar` reads them. This avoids races between a file's recorded size and concurrent writes. The filter applies these state-relative rules under each backed-up state directory: @@ -263,7 +283,16 @@ These rules do not filter workspace files outside the state directory. They also Chromium singleton entries coordinate one running browser on one host and are recreated when that profile starts; the rest of the profile's `user-data/` remains in the archive. Sandbox skills workspaces are generated copies of current skill sources and are materialized again when OpenClaw prepares the next sandbox context after restore; adjacent sandbox registry and other durable state remain included. -SQLite databases under the state directory are captured with SQLite's online backup API and compacted offline with `VACUUM` so deleted-page remnants do not enter the archive, and live WAL/SHM files are not copied. A plugin-owned database that requires unavailable owner-defined SQLite capabilities fails closed rather than falling back to a direct file copy. SQLite files included through workspace backups are copied as workspace files and are not covered by the compaction guarantee. +SQLite databases owned by the state directory or any configured agent directory +are captured with SQLite's online backup API and compacted offline with +`VACUUM`, including custom agent roots covered by a workspace or managed state +asset. Committed WAL changes are included, deleted-page remnants and transient +leases are removed, sidecars are omitted, and canonical OpenClaw databases must +match their expected role and agent owner. Unsafe aliasing or an owner mismatch +fails closed. A plugin-owned database that requires unavailable owner-defined +SQLite capabilities also fails closed rather than falling back to a direct file +copy. Other workspace SQLite files outside configured agent roots remain raw +workspace files and do not receive the SQLite snapshot or compaction guarantee. Installed plugin source and manifest files under the state directory's `extensions/` tree are included, but their nested `node_modules/` dependency trees are skipped as rebuildable install artifacts. After restoring an archive, use `openclaw plugins update ` or reinstall with `openclaw plugins install --force` if a restored plugin reports missing dependencies. @@ -273,7 +302,19 @@ Agent-scoped temporary trees under `agents//agent/**/{tmp,.tmp}/` are a Symbolic links are archived as link metadata and are never followed. Relative links are retained only when both the link and its lexical target remain within backup assets declared in `manifest.json`; links between declared assets and dangling links within an asset are allowed. Absolute links, links containing backslashes, and links escaping the archive root or every declared asset are rejected during both creation and verification. -Installer-managed and rebuildable runtime roots under the state directory are also skipped: `dev/`, `git/`, `npm/`, legacy `npm-runtime/`, `tmp/`, and `tools/`. These contain managed checkouts, package trees, compiler caches, temporary files, and downloaded runtimes rather than authoritative user state; reinstall or update the corresponding runtime or plugin after restore. An explicitly configured config file, credentials directory, or workspace inside one of these roots remains included. +Installer-managed and rebuildable runtime roots under the state directory are +also skipped: `dev/`, `git/`, `npm/`, legacy `npm-runtime/`, `tmp/`, and +`tools/`. These contain managed checkouts, package trees, compiler caches, +temporary files, and downloaded runtimes rather than authoritative user state; +reinstall or update the corresponding runtime or plugin after restore. +Effectively activated, loadable plugins can declare additional durable or +regenerable state- or agent-relative roots through +[`backupResources`](/plugins/manifest#backupresources-reference). Disabled or +unloadable plugins cannot exclude data. Explicit config, credentials, workspace, +agent, and plugin-included paths override exclusions, and any excluded parent +remains traversable to reach those protected descendants. Names such as `tmp` +and `.tmp` are not blanket exclusions in custom agent directories; only an +applicable owner declaration can omit their durable-looking siblings. Local edits inside a managed `dev/` checkout are developer source, not OpenClaw product state, and are not included. Commit and push those edits or copy the checkout separately before relying on a state backup. @@ -281,7 +322,12 @@ Local edits inside a managed `dev/` checkout are developer source, not OpenClaw `openclaw backup` bypasses the normal config preflight so it can still help during recovery. Workspace discovery depends on a valid config, so `openclaw backup create` fails fast when the config file exists but is invalid and workspace backup is still enabled. -For a partial backup in that situation, rerun with `--no-include-workspace`: it keeps state, config, and the external credentials directory in scope while skipping workspace discovery entirely. +For a partial backup in that situation, rerun with +`--no-include-workspace`: it keeps state, config, and the external credentials +directory in scope without workspace discovery. Because malformed configuration +also prevents resolving custom agent ownership and effectively activated plugin +resources, the result records those unresolved scopes as skipped diagnostics; +do not treat that recovery archive as a complete backup. `--only-config` also works when the config is malformed, since it does not parse the config for workspace discovery. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 4a26cf545183..37b972176187 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -18,9 +18,9 @@ Each agent has its own: - **Workspace**: files, `AGENTS.md`/`SOUL.md`/`USER.md`, local notes, persona rules. - **State directory** (`agentDir`): auth profiles, model registry, per-agent config. -- **Session store**: chat history and routing state in `~/.openclaw/agents//agent/openclaw-agent.sqlite`. +- **Session store**: chat history and routing state in `/openclaw-agent.sqlite`. -Auth profiles are per-agent, read from: +Auth profiles are per-agent, read from `/openclaw-agent.sqlite`. With the default layout, that resolves to: ```text ~/.openclaw/agents//agent/openclaw-agent.sqlite @@ -54,7 +54,7 @@ when personas must not share compiled wiki knowledge. | Default agent's workspace | `/workspace` (`~/.openclaw-/workspace` for a named profile) | `agents.entries.*.workspace`, then `agents.defaults.workspace`, or `OPENCLAW_WORKSPACE_DIR` | | Other agents' workspace | `/workspace-` (or `/` when set) | `agents.entries.*.workspace` | | Agent dir | `~/.openclaw/agents//agent` | `agents.entries.*.agentDir` | -| Sessions and transcripts | `~/.openclaw/agents//agent/openclaw-agent.sqlite` | — | +| Sessions and transcripts | `/openclaw-agent.sqlite` | `agents.entries.*.agentDir` | | Legacy/archive session artifacts | `~/.openclaw/agents//sessions` | — | ### Single-agent mode (default) @@ -108,7 +108,7 @@ the configured roster, its children appear at the root of the tree. openclaw agents add social ``` - Each agent gets its own workspace with `SOUL.md`, `AGENTS.md`, and optional `USER.md`, plus a dedicated `agentDir` and session store under `~/.openclaw/agents/`. + Each agent gets its own workspace with `SOUL.md`, `AGENTS.md`, and optional `USER.md`, plus a dedicated `agentDir` and session store. By default, those agent files live under `~/.openclaw/agents/`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index cd29ab87f7cb..c519be22bdd4 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1333,7 +1333,7 @@ Validation: - Canonical matrix: [SecretRef Credential Surface](/reference/secretref-credential-surface) - `secrets apply` targets supported `openclaw.json` credential paths. -- `auth-profiles.json` refs are included in runtime resolution and audit coverage. +- Per-agent auth-profile refs are included in runtime resolution and audit coverage. ### Secret providers config @@ -1394,8 +1394,8 @@ Notes: } ``` -- Per-agent profiles are stored at `/auth-profiles.json`. -- `auth-profiles.json` supports value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`) for static credential modes. +- Per-agent profiles are stored in `/openclaw-agent.sqlite` (`auth_profile_store`). +- Stored auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`) for static credential modes. - Legacy flat `auth-profiles.json` maps such as `{ "provider": { "apiKey": "..." } }` are not a runtime format; `openclaw doctor --fix` rewrites them to canonical `provider:default` API-key profiles with a `.legacy-flat.*.bak` backup. - OAuth-mode profiles (`auth.profiles..mode = "oauth"`) do not support SecretRef-backed auth-profile credentials. - Static runtime credentials come from in-memory resolved snapshots; legacy static `auth.json` entries are scrubbed when discovered. diff --git a/docs/install/backups.md b/docs/install/backups.md index 0146ffc20478..1f62d4d2d987 100644 --- a/docs/install/backups.md +++ b/docs/install/backups.md @@ -10,12 +10,13 @@ title: "Backups" # Backups OpenClaw keeps its authoritative state in SQLite: one global control-plane -database plus one database per agent, all under the state directory (usually -`~/.openclaw`). See [Database schemas](/reference/database-schemas) for the -exact layout. This guide covers protecting that state: one-off archives, -per-database snapshots, scheduling, offsite copies, and continuous -replication for installs that should not re-upload whole databases on every -backup. +database under the state directory (usually `~/.openclaw`), plus one database +per configured agent at `/openclaw-agent.sqlite`. Agent directories +default to locations under the state directory but can be configured outside +it. See [Database schemas](/reference/database-schemas) for the exact layout. +This guide covers protecting that state: one-off archives, per-database +snapshots, scheduling, offsite copies, and continuous replication for installs +that should not re-upload whole databases on every backup. Never copy live `.sqlite`, `-wal`, `-shm`, or `-journal` files as a backup. The databases are written while the Gateway runs, and raw file copies of a @@ -48,12 +49,21 @@ committed state safely. openclaw backup create --output ~/Backups/openclaw --verify ``` -This writes a timestamped `.tar.gz` covering state, config, credentials, -sessions, and (by default) workspaces, then validates the archive manifest -and payload. SQLite databases inside the archive are captured with SQLite's -online backup API and compacted, so the archive is safe to create while the -Gateway runs. [Backup CLI](/cli/backup) documents every flag, the volatile -files that are intentionally skipped, and verification details. +This writes a timestamped `.tar.gz` covering state, config, credentials, every +configured agent directory, and (by default) workspaces, then validates the +archive manifest and payload. Agent directories remain included when +`--no-include-workspace` is set, even if their configured locations are outside +the state directory. OpenClaw-owned SQLite databases, including agent databases +inside workspace or managed-state assets, are captured with SQLite's online +backup API, owner-verified, sanitized, and compacted. Other SQLite files in +workspaces remain ordinary workspace files. [Backup CLI](/cli/backup) +documents every flag, owner-declared regenerable resources, volatile files, +and verification details. + +If the configuration is malformed, `--no-include-workspace` can still produce a +partial recovery archive for state, config, and credentials. Its skipped +diagnostics identify agent and plugin ownership that could not be resolved; +repair the configuration before relying on an archive as complete. Archives are full copies: each run re-uploads everything. They are the right tool before an update, reset, uninstall, or machine move, and a reasonable @@ -77,6 +87,12 @@ Each run publishes one verified snapshot directory (`manifest.json` plus deleted-page remnants do not inflate them, and every snapshot records a SHA-256 that `openclaw backup sqlite verify` rechecks later. +`--agent ` resolves the database from that agent's configured `agentDir`, +including roots outside the state directory. The same owner-derived lookup +applies to explicit Git agent backups, `--all`, and scheduled Git backups. +Verifying or restoring a historical artifact by agent id does not require that +agent to exist in the current configuration. + Snapshot repositories are local directories. Scheduling, upload, retention, and restore-on-boot are intentionally left to the operator; the sections below cover them. @@ -84,7 +100,8 @@ below cover them. ## Schedule backups The recommended schedule is one Gateway-owned automation. This example backs -up every registered database daily and pushes the current branch to `origin`. +up the shared database and every configured agent database daily, including +custom agent roots, and pushes the current branch to `origin`. Pushing requires the repository to have an `origin` remote first, so initialize it once before enabling a pushed schedule: @@ -226,6 +243,9 @@ offline: litestream restore -o ./restored-openclaw.sqlite s3://openclaw-backups/state ``` +For an agent with a custom `agentDir`, replace the example's default agent +database path with its configured `/openclaw-agent.sqlite`. + Litestream replicates database bytes only. Config, credentials files, and workspaces still need one of the file-based paths above, and the replicated data is as sensitive as the archives, so apply the same bucket access and @@ -294,12 +314,14 @@ ARCHIVE=./2026-03-09T08-00-00.000+08-00-openclaw-backup.tar.gz openclaw backup restore "$ARCHIVE" --target ./restored-openclaw ``` -The target must not exist or must be empty. OpenClaw verifies archive structure, -the manifest, hardlinks, symbolic-link containment, and SQLite databases before it writes the target. A -non-empty target is refused, and a failed extraction cleans its incomplete -output. The command never touches the live state directory and has no force or -in-place mode. Treat the restored directory as sensitive: it can contain -credentials, auth profiles, sessions, and workspace data. +The target must not exist or must be empty, and it must not be inside the live +state directory or any configured live agent directory. OpenClaw verifies +archive structure, the manifest, hardlinks, symbolic-link containment, and +SQLite databases before it writes the target. A non-empty target is refused, +and a failed extraction cleans its incomplete output. The command never writes +into live state or agent roots and has no force or in-place mode. Treat the +restored directory as sensitive: it can contain credentials, auth profiles, +sessions, and workspace data. Restoring an archive is time travel. Messaging-channel credentials with @@ -312,10 +334,15 @@ credentials, auth profiles, sessions, and workspace data. `plugin-skills/` symlink index from current plugin metadata. -The manifest records `archiveRoot`, the original paths under `paths`, and an -`assets[]` list. Each asset includes its `kind`, original `sourcePath`, and -`archivePath` inside the tarball. Use those fields as the source of truth; do -not derive the archive root from the archive filename. +The schema-version-1 manifest records `archiveRoot`, the original paths under +`paths`, an `assets[]` list, and additive configured-agent ownership metadata. +Each asset includes its `kind`, original `sourcePath`, and `archivePath` inside +the tarball. An external custom agent root has kind `agent` when it needs its +own source; roots already covered by a state or workspace asset appear in +ownership metadata without duplicating archive entries. Use the asset and +ownership fields as the source of truth; do not derive the archive root from +the archive filename or reconstruct agent paths from the default layout. Older +archives without additive ownership metadata remain verifiable. The archive layout is: @@ -328,11 +355,13 @@ The archive layout is: To activate, stop the Gateway and any node hosts that use the restored files. Make a fresh backup of current state or move it aside. Then move the extracted -state asset into place, or point `OPENCLAW_STATE_DIR` at that asset, and run -`openclaw doctor` before restarting the Gateway. On a new machine or under a -different home directory, use the manifest to map config, credentials, and -workspace assets to their new paths. See [Updating](/install/updating#rollback) -for the rollback workflow. +state asset into place, or point `OPENCLAW_STATE_DIR` at that asset. Restore +every custom agent root using its recorded agent id and original source path; +either preserve its configured `agentDir` or update that setting to its new +location. On a new machine or under a different home directory, also use the +manifest to map config, credentials, and workspace assets to their new paths. +Run `openclaw doctor` before restarting the Gateway. See +[Updating](/install/updating#rollback) for the rollback workflow. ### Restore a database diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index a926bedd76ad..f0cdb99b97b0 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -35,6 +35,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m - static capability-ownership snapshots (`contracts`) - dashboard widget data bindings and action verbs - static MCP servers that should exist while the plugin is enabled +- durable and regenerable state- or agent-relative backup resources - QA runner metadata the shared `openclaw qa` host can inspect - channel-specific config metadata merged into catalog and validation surfaces @@ -163,6 +164,7 @@ See [Plugins](/tools/plugin) for the full plugin system guide, and [Capability m | `providerAuthAliases` | No | `Record` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. | | `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | | `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. | +| `backupResources` | No | `object[]` | Manifest-owned durable or regenerable state- or agent-relative backup resources. Applied only for effectively activated, loadable plugins without executing their runtime. See [backupResources reference](#backupresources-reference). | | `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. | | `doctorContract` | No | `object` | Declares which dynamic doctor-contract surfaces the plugin artifact exports so doctor loads only relevant modules. | | `sessionRouteStateOwners` | No | `object[]` | Static session-route ownership for doctor cleanup. Each entry declares an `id`, `label`, and optional `providerIds`, `runtimeIds`, `cliSessionKeys`, and `authProfilePrefixes`. | @@ -197,6 +199,53 @@ Set `doctorContract.configRepair: true` when the doctor-contract module exports non-empty `legacyConfigRules`, a `normalizeCompatibilityConfig` function, or both. One declaration covers the complete config-repair artifact. +## backupResources reference + +Use `backupResources` to declare plugin-owned durable data that backups must +include, or generated data that OpenClaw can safely omit and regenerate after +restore. The backup planner reads this metadata without loading plugin runtime +or modifying plugin files. Only effectively activated, loadable plugins +contribute resources; disabled or unloadable plugins cannot exclude data. + +```json +{ + "backupResources": [ + { + "disposition": "include", + "scope": "state", + "relativePath": "example-plugin/durable-state" + }, + { + "disposition": "regenerable", + "scope": "agent", + "relativePath": "example-plugin/generated-cache" + } + ] +} +``` + +Each entry is a closed object with exactly these fields: + +| Field | Required | Type | What it means | +| -------------- | -------- | ---------------------------- | -------------------------------------------------------------------------------------- | +| `disposition` | Yes | `"include" \| "regenerable"` | Protect durable data from exclusion, or identify data that can be omitted and rebuilt. | +| `scope` | Yes | `"state" \| "agent"` | Resolve the resource under the state directory or each configured agent directory. | +| `relativePath` | Yes | `string` | Strict relative POSIX path contained by the selected scope's authoritative root. | + +Plugin identity and its trusted root come from manifest discovery; resource +entries cannot declare or override an owner. `relativePath` must not be empty +or absolute and must not contain backslashes, NULs, empty path segments, `.`, +`..`, Windows drive or UNC prefixes, URI-like values, or any path that escapes +its selected anchor. Invalid entries are rejected rather than normalized. + +The planner deduplicates resources deterministically. A narrower `regenerable` +declaration wins over a broad configured state or agent root. Among plugin +resource declarations, only an explicit nested `include` protects a descendant +and keeps its excluded ancestors traversable. Explicit config, credentials, +workspace, and nested agent paths also remain protected. Omit only data the +plugin can recreate. +`openclaw backup create --only-config` does not inspect plugin backup metadata. + ## MCP server reference `mcpServers` lets a native plugin ship an MCP server, including an MCP App, without requiring operators to duplicate its static process definition in `openclaw.json`: diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index 7f83d3e258a4..4fbf62116e10 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -21,7 +21,7 @@ Each agent in a multi-agent setup can override the global sandbox and tool polic -Auth is scoped by agent: each agent has its own `agentDir` auth store in `~/.openclaw/agents//agent/openclaw-agent.sqlite`. Never reuse `agentDir` across agents. Agents can read through to the default/main agent's auth profiles when they do not have a local profile, but OAuth refresh tokens are not cloned into secondary agent stores. If you copy credentials manually, copy only portable static `api_key` or `token` profiles. +Auth is scoped by agent: each agent has its own `/openclaw-agent.sqlite` auth store (by default, `~/.openclaw/agents//agent/openclaw-agent.sqlite`). Never reuse `agentDir` across agents. Agents can read through to the default/main agent's auth profiles when they do not have a local profile, but OAuth refresh tokens are not cloned into secondary agent stores. If you copy credentials manually, copy only portable static `api_key` or `token` profiles. --- diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index cb882c693307..5c35f3a2b738 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -1,5 +1,22 @@ { "id": "codex", + "backupResources": [ + { + "disposition": "regenerable", + "scope": "agent", + "relativePath": "codex-home/tmp/arg0" + }, + { + "disposition": "regenerable", + "scope": "agent", + "relativePath": "codex-home/.tmp/plugins" + }, + { + "disposition": "regenerable", + "scope": "agent", + "relativePath": "codex-home/.tmp/bundled-marketplaces" + } + ], "doctorContract": { "configRepair": true, "stateMigrations": true diff --git a/src/commands/backup-git.test.ts b/src/commands/backup-git.test.ts index 3619bbfd3d0a..0da86d3e6d86 100644 --- a/src/commands/backup-git.test.ts +++ b/src/commands/backup-git.test.ts @@ -6,7 +6,6 @@ import { createTestRuntime } from "./test-runtime-config-helpers.js"; const mocks = vi.hoisted(() => ({ createGitBackup: vi.fn(), getRuntimeConfig: vi.fn(), - listRegisteredAgentDatabases: vi.fn(), recordBackupRunOutcome: vi.fn(), restoreGitBackupRef: vi.fn(), verifyGitBackupRef: vi.fn(), @@ -29,10 +28,6 @@ vi.mock("../state/backup-run-records.js", () => ({ recordBackupRunOutcome: mocks.recordBackupRunOutcome, })); -vi.mock("../state/openclaw-agent-db.js", () => ({ - listOpenClawRegisteredAgentDatabases: mocks.listRegisteredAgentDatabases, -})); - import { backupGitCreateCommand, backupGitRestoreCommand, @@ -50,7 +45,6 @@ describe("Git backup command agent selection", () => { mocks.getRuntimeConfig.mockReset().mockReturnValue({ agents: { list: [{ id: "main" }, { id: "ops-team" }] }, }); - mocks.listRegisteredAgentDatabases.mockReset().mockReturnValue([]); mocks.recordBackupRunOutcome.mockReset(); mocks.restoreGitBackupRef.mockReset().mockResolvedValue({ commit: "backup-commit", @@ -69,6 +63,10 @@ describe("Git backup command agent selection", () => { }); it("creates a backup for a configured normalized agent", async () => { + const agentDir = path.resolve("/tmp/external-agent"); + mocks.getRuntimeConfig.mockReturnValue({ + agents: { entries: { "ops-team": { agentDir } } }, + }); await backupGitCreateCommand(createTestRuntime(), { repository: "/tmp/repository", agents: ["Ops Team"], @@ -76,7 +74,12 @@ describe("Git backup command agent selection", () => { expect(mocks.createGitBackup).toHaveBeenCalledWith( expect.objectContaining({ - databases: [expect.objectContaining({ identity: { role: "agent", agentId: "ops-team" } })], + databases: [ + { + identity: { role: "agent", agentId: "ops-team" }, + path: path.join(agentDir, "openclaw-agent.sqlite"), + }, + ], }), ); }); @@ -100,19 +103,51 @@ describe("Git backup command agent selection", () => { expect(mocks.createGitBackup).not.toHaveBeenCalled(); }); - it.each([ - { label: "all", scope: { all: true } }, - { label: "global", scope: { global: true } }, - ])("keeps the $label Git create scope independent of configured agents", async ({ scope }) => { + it("keeps the global Git create scope independent of configured agents", async () => { await backupGitCreateCommand(createTestRuntime(), { repository: "/tmp/repository", - ...scope, + global: true, }); expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); expect(mocks.createGitBackup).toHaveBeenCalledOnce(); }); + it("resolves every current agent and its configured root for an all-scope backup", async () => { + const mainAgentDir = path.resolve("/tmp/external-main"); + const opsAgentDir = path.resolve("/tmp/external-ops"); + mocks.getRuntimeConfig.mockReturnValue({ + agents: { + entries: { + main: { agentDir: mainAgentDir }, + "ops-team": { agentDir: opsAgentDir }, + }, + }, + }); + + await backupGitCreateCommand(createTestRuntime(), { + repository: "/tmp/repository", + all: true, + }); + + expect(mocks.getRuntimeConfig).toHaveBeenCalledOnce(); + expect(mocks.createGitBackup).toHaveBeenCalledWith( + expect.objectContaining({ + databases: [ + expect.objectContaining({ identity: { role: "global" } }), + { + identity: { role: "agent", agentId: "main" }, + path: path.join(mainAgentDir, "openclaw-agent.sqlite"), + }, + { + identity: { role: "agent", agentId: "ops-team" }, + path: path.join(opsAgentDir, "openclaw-agent.sqlite"), + }, + ], + }), + ); + }); + it("keeps the --all plus explicit-scope conflict ahead of agent validation", async () => { await expect( backupGitCreateCommand(createTestRuntime(), { diff --git a/src/commands/backup-git.ts b/src/commands/backup-git.ts index 88b557987d1d..b5a5886b900f 100644 --- a/src/commands/backup-git.ts +++ b/src/commands/backup-git.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; +import { listAgentIds, resolveConfiguredAgentId } from "../agents/agent-scope-config.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -14,11 +14,9 @@ import { verifyGitBackupRef, } from "../snapshot/git-backup.js"; import { recordBackupRunOutcome } from "../state/backup-run-records.js"; -import { listOpenClawRegisteredAgentDatabases } from "../state/openclaw-agent-db.js"; -import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { shortenHomePath } from "../utils.js"; -import { resolveRequiredBackupPath } from "./backup-shared.js"; +import { resolveBackupAgentRoot, resolveRequiredBackupPath } from "./backup-shared.js"; type BackupGitCreateOptions = { repository?: string; @@ -57,10 +55,13 @@ async function resolveCreateDatabases(runtime: RuntimeEnv, options: BackupGitCre if (!options.all && !explicit) { throw new Error("Choose at least one Git backup scope: --all, --global, or --agent ."); } - let agents: string[] = []; - if (normalizedAgents.length > 0) { + let agents: Array<{ agentId: string; databasePath: string }> = []; + if (options.all || normalizedAgents.length > 0) { const config = getRuntimeConfig({ skipPluginValidation: true }); - agents = normalizedAgents.map((agent) => resolveConfiguredAgentId(config, agent)); + const agentIds = options.all + ? listAgentIds(config).toSorted() + : normalizedAgents.map((agent) => resolveConfiguredAgentId(config, agent)); + agents = await Promise.all(agentIds.map((agentId) => resolveBackupAgentRoot(config, agentId))); } const databases: Array<{ path: string; @@ -72,21 +73,15 @@ async function resolveCreateDatabases(runtime: RuntimeEnv, options: BackupGitCre identity: { role: "global" }, }); } - // Registry rows can carry stale or foreign absolute paths (deleted agents, - // retired temp state dirs), so --all resolves each distinct agent id to its - // canonical database under the current state dir and skips absent files - // instead of aborting the whole scheduled run on one dead registration. - const allAgentIds = options.all - ? [...new Set(listOpenClawRegisteredAgentDatabases().map((entry) => entry.agentId))].toSorted() - : agents; - for (const agentId of allAgentIds) { - const canonicalPath = resolveOpenClawAgentSqlitePath({ agentId }); + // Config owns both the current roster and each agent root; durable registry + // rows can retain stale paths after an agent moves or is removed. + for (const { agentId, databasePath } of agents) { let resolvedPath: string; try { - resolvedPath = await fs.realpath(canonicalPath); + resolvedPath = await fs.realpath(databasePath); } catch (error) { if (options.all && (error as NodeJS.ErrnoException).code === "ENOENT") { - runtime.error(`Warning: skipping agent ${agentId}: no database at ${canonicalPath}`); + runtime.error(`Warning: skipping agent ${agentId}: no database at ${databasePath}`); continue; } throw error; diff --git a/src/commands/backup-resource-inventory.ts b/src/commands/backup-resource-inventory.ts new file mode 100644 index 000000000000..bb7a90c2b95f --- /dev/null +++ b/src/commands/backup-resource-inventory.ts @@ -0,0 +1,249 @@ +/** Frozen backup ownership and resource policy shared by archive traversal and SQLite discovery. */ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { hasErrnoCode } from "../infra/errno.js"; +import type { ResolvedPluginBackupResource } from "../plugins/manifest-backup-resources.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { isPathWithin } from "./cleanup-utils.js"; + +export type BackupAgentRoot = Readonly<{ + agentId: string; + sourcePath: string; + databasePath: string; +}>; + +export type BackupRegenerableKind = + | "agent temporary files" + | "managed state" + | "plugin skills" + | "plugin resource" + | "plugin dependencies"; + +type BackupRegenerableRoot = Readonly<{ + kind: BackupRegenerableKind; + sourcePath: string; +}>; + +export type BackupResourceInventory = Readonly<{ + stateDir: string; + agentRoots: readonly BackupAgentRoot[]; + regenerableRoots: readonly BackupRegenerableRoot[]; + isIncluded: (sourcePath: string) => boolean; + isTraversable: (sourcePath: string) => boolean; + isPackageContent: (sourcePath: string) => boolean; +}>; + +const MANAGED_STATE_ROOTS = ["dev", "git", "npm", "npm-runtime", "tmp", "tools"] as const; + +async function listDefaultAgentTemporaryRoots( + stateDir: string, + agentRoots: readonly BackupAgentRoot[], +): Promise { + // Name-based scratch ownership belongs only to the shipped default layout; + // a configured custom root keeps its durable tmp trees even when nested there. + const customAgentRoots = agentRoots.filter( + ({ agentId, sourcePath }) => sourcePath !== path.join(stateDir, "agents", agentId, "agent"), + ); + const temporaryRoots: string[] = []; + + const visit = async (directoryPath: string): Promise => { + if (customAgentRoots.some(({ sourcePath }) => isPathWithin(directoryPath, sourcePath))) { + return; + } + + let entries: Dirent[]; + try { + entries = await fs.readdir(directoryPath, { withFileTypes: true }); + } catch (error) { + if (hasErrnoCode(error, "ENOENT") || hasErrnoCode(error, "ENOTDIR")) { + return; + } + throw error; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const entryPath = path.join(directoryPath, entry.name); + if (customAgentRoots.some(({ sourcePath }) => isPathWithin(entryPath, sourcePath))) { + continue; + } + if (entry.name === "tmp" || entry.name === ".tmp") { + temporaryRoots.push(entryPath); + continue; + } + await visit(entryPath); + } + }; + + let agentDirectories: Dirent[]; + try { + agentDirectories = await fs.readdir(path.join(stateDir, "agents"), { withFileTypes: true }); + } catch (error) { + if (hasErrnoCode(error, "ENOENT") || hasErrnoCode(error, "ENOTDIR")) { + return temporaryRoots; + } + throw error; + } + for (const directory of agentDirectories) { + if (directory.isDirectory()) { + await visit(path.join(stateDir, "agents", directory.name, "agent")); + } + } + return temporaryRoots; +} + +/** Build the one immutable owner inventory used by backup planning and archive consumers. */ +export async function createBackupResourceInventory(params: { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs: readonly string[]; + agentRoots: readonly BackupAgentRoot[]; + pluginResources: readonly ResolvedPluginBackupResource[]; + pluginRoots: readonly string[]; + onlyConfig?: boolean; +}): Promise { + const stateDir = path.resolve(params.stateDir); + const agentRoots = Object.freeze( + params.agentRoots.map((root) => + Object.freeze({ + agentId: root.agentId, + sourcePath: path.resolve(root.sourcePath), + databasePath: path.resolve(root.databasePath), + }), + ), + ); + const protectedPathSet = new Set([ + path.resolve(params.configPath), + resolveOpenClawStateSqlitePath({ ...process.env, OPENCLAW_STATE_DIR: stateDir }), + ]); + const regenerableRoots: BackupRegenerableRoot[] = []; + const exclude = (kind: BackupRegenerableKind, sourcePath: string): void => { + regenerableRoots.push({ kind, sourcePath: path.resolve(sourcePath) }); + }; + + if (!params.onlyConfig) { + protectedPathSet.add(path.resolve(params.oauthDir)); + for (const workspaceDir of params.workspaceDirs) { + protectedPathSet.add(path.resolve(workspaceDir)); + } + for (const root of agentRoots) { + protectedPathSet.add(root.sourcePath); + protectedPathSet.add(root.databasePath); + } + for (const root of MANAGED_STATE_ROOTS) { + exclude("managed state", path.join(stateDir, root)); + } + for (const temporaryRoot of await listDefaultAgentTemporaryRoots(stateDir, agentRoots)) { + exclude("agent temporary files", temporaryRoot); + } + exclude("plugin skills", path.join(stateDir, "plugin-skills")); + + for (const resource of params.pluginResources) { + const anchors = resource.scope === "state" ? [{ sourcePath: stateDir }] : agentRoots; + for (const anchor of anchors) { + const sourcePath = path.resolve(anchor.sourcePath, ...resource.relativePath.split("/")); + if (!isPathWithin(sourcePath, anchor.sourcePath)) { + throw new Error( + `Plugin ${resource.pluginId} backup resource escapes its ${resource.scope} root: ${resource.relativePath}`, + ); + } + if (resource.disposition === "include") { + protectedPathSet.add(sourcePath); + } else { + exclude("plugin resource", sourcePath); + } + } + } + for (const pluginRoot of params.pluginRoots) { + exclude("plugin dependencies", path.join(pluginRoot, "node_modules")); + } + } + + const seenRegenerableRoots = new Set(); + const uniqueRegenerableRoots = Object.freeze( + regenerableRoots + .toSorted( + (left, right) => + left.sourcePath.localeCompare(right.sourcePath) || left.kind.localeCompare(right.kind), + ) + .filter((resource) => { + const key = `${resource.kind}\0${resource.sourcePath}`; + if (seenRegenerableRoots.has(key)) { + return false; + } + seenRegenerableRoots.add(key); + return true; + }), + ); + const protectedPaths = Object.freeze([...protectedPathSet].toSorted()); + const excludedPaths = Object.freeze( + uniqueRegenerableRoots + .map((resource) => resource.sourcePath) + .toSorted((left, right) => right.length - left.length || left.localeCompare(right)), + ); + + const isIncluded = (sourcePath: string): boolean => { + const candidate = path.resolve(sourcePath); + const exclusion = excludedPaths.find((excludedPath) => isPathWithin(candidate, excludedPath)); + if (!exclusion) { + return true; + } + // Broad state/agent roots cannot resurrect a narrower owner exclusion; + // only an explicit include inside the excluded subtree overrides it. + return protectedPaths.some( + (protectedPath) => + isPathWithin(candidate, protectedPath) && isPathWithin(protectedPath, exclusion), + ); + }; + const isTraversable = (sourcePath: string): boolean => { + const candidate = path.resolve(sourcePath); + return ( + isIncluded(candidate) || + protectedPaths.some((protectedPath) => isPathWithin(protectedPath, candidate)) + ); + }; + const isPackageContent = (sourcePath: string): boolean => { + const candidate = path.resolve(sourcePath); + // Explicit config, workspace, agent, and plugin ownership may live inside + // node_modules; keep both those paths and their traversal ancestors. + if ( + protectedPaths.some( + (protectedPath) => + isPathWithin(candidate, protectedPath) || isPathWithin(protectedPath, candidate), + ) + ) { + return false; + } + if (!isPathWithin(candidate, stateDir)) { + return false; + } + const segments = path.relative(stateDir, candidate).split(path.sep); + // Default-layout agent ids can themselves be node_modules. Preserve the + // canonical database, its sidecars, and traversal ancestors as agent state. + if ( + segments[0] === "agents" && + segments[1] && + (segments.length === 2 || + (segments[2] === "agent" && + (segments.length === 3 || + (segments.length === 4 && + /^openclaw-agent\.sqlite(?:-wal|-shm|-journal)?$/u.test(segments[3] ?? ""))))) + ) { + return false; + } + return segments.includes("node_modules"); + }; + + return Object.freeze({ + stateDir, + agentRoots, + regenerableRoots: uniqueRegenerableRoots, + isIncluded, + isTraversable, + isPackageContent, + }); +} diff --git a/src/commands/backup-restore.test.ts b/src/commands/backup-restore.test.ts index 8cb3b549abda..d2984907ca91 100644 --- a/src/commands/backup-restore.test.ts +++ b/src/commands/backup-restore.test.ts @@ -358,6 +358,52 @@ describe("backupRestoreCommand", () => { ); }); + it("rejects a staging target inside a configured external live agent directory", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-restore-external-agent-", + scenario: "minimal", + }, + async (state) => { + const agentDir = state.path("external-agent"); + const targetPath = path.join(agentDir, "restore-target"); + await fs.mkdir(agentDir, { recursive: true }); + await state.writeConfig({ agents: { entries: { main: { agentDir } } } }); + + await expect( + backupRestoreCommand(createRuntime(), { + archive: state.path("missing-backup.tar.gz"), + target: targetPath, + }), + ).rejects.toThrow(/outside the live OpenClaw agent directory/iu); + await expect(fs.lstat(targetPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + }); + + it("keeps restore available when the live config is malformed", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-restore-invalid-config-", + scenario: "minimal", + }, + async (state) => { + const archivePath = state.path("backup.tar.gz"); + const targetPath = state.path("restore-target"); + const archiveRoot = "2026-08-12T00-00-00.000Z-openclaw-backup"; + const payloadPath = buildBackupArchivePath(archiveRoot, "/tmp/openclaw.json"); + await writeArchive({ archivePath, archiveRoot, payloadPath }); + await fs.writeFile(state.configPath, '{"agents":{"entries":', "utf8"); + + await expect( + backupRestoreCommand(createRuntime(), { archive: archivePath, target: targetPath }), + ).resolves.toMatchObject({ targetPath }); + }, + ); + }); + it("verifies a corrupt archive before touching an empty target", async () => { await withOpenClawTestState( { diff --git a/src/commands/backup-restore.ts b/src/commands/backup-restore.ts index 9f4d2f04ec64..8967c9ee3710 100644 --- a/src/commands/backup-restore.ts +++ b/src/commands/backup-restore.ts @@ -2,17 +2,19 @@ import fs from "node:fs/promises"; import path from "node:path"; import * as tar from "tar"; -import { resolveStateDir } from "../config/config.js"; +import { readConfigFileSnapshot, resolveStateDir } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; import { BACKUP_MAX_DECOMPRESSION_RATIO, canonicalizePathForContainment, + resolveBackupAgentRoots, resolveRequiredBackupPath, } from "./backup-shared.js"; import { verifyBackupArchive } from "./backup-verify.js"; import { isPathWithin } from "./cleanup-utils.js"; +import { resolveUpgradeConfigSnapshot } from "./doctor/shared/automatic-upgrade-config-repair.js"; const BACKUP_RESTORE_WARNINGS = [ "Restoring an archive is time travel: every restored state surface rolls back to the archive timestamp.", @@ -51,6 +53,19 @@ async function assertTargetOutsideLiveState(targetPath: string): Promise { `Backup restore target must be outside the live OpenClaw state directory: ${targetPath}`, ); } + const configSnapshot = await readConfigFileSnapshot({ observe: false }); + const discoverySnapshot = resolveUpgradeConfigSnapshot(configSnapshot); + if (!discoverySnapshot) { + return; + } + const agentRoots = await resolveBackupAgentRoots(discoverySnapshot.config); + for (const { sourcePath } of agentRoots) { + if (isPathWithin(canonicalTarget, sourcePath)) { + throw new Error( + `Backup restore target must be outside the live OpenClaw agent directory: ${targetPath}`, + ); + } + } } async function prepareRestoreTarget(targetPath: string): Promise<{ created: boolean }> { diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts index 815968e08dfc..0aaf408478da 100644 --- a/src/commands/backup-shared.ts +++ b/src/commands/backup-shared.ts @@ -1,15 +1,27 @@ // Backup planning helpers for archive naming, payload paths, and deduplicated asset selection. import fs from "node:fs/promises"; import path from "node:path"; +import { listAgentIds, resolveAgentDir } from "../agents/agent-scope-config.js"; import { readConfigFileSnapshot, resolveConfigPath, resolveOAuthDir, resolveStateDir, } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveActivatedPluginBackupInventory, + type ActivatedPluginBackupInventory, +} from "../plugins/manifest-backup-resources.js"; import { pathExists, resolveUserPath, shortenHomePath } from "../utils.js"; +import { + createBackupResourceInventory, + type BackupAgentRoot, + type BackupRegenerableKind, + type BackupResourceInventory, +} from "./backup-resource-inventory.js"; import { buildCleanupPlan, isPathWithin } from "./cleanup-utils.js"; -import { planUpgradeConfigRepair } from "./doctor/shared/automatic-upgrade-config-repair.js"; +import { resolveUpgradeConfigSnapshot } from "./doctor/shared/automatic-upgrade-config-repair.js"; // DEFLATE can legitimately encode zero-filled sparse ranges just over 1000:1. // Keep bounded headroom without disabling node-tar's decompression bomb guard. @@ -26,8 +38,8 @@ export function resolveRequiredBackupPath( return resolveUserPath(trimmed); } -type BackupAssetKind = "state" | "config" | "credentials" | "workspace"; -type BackupSkipReason = "covered" | "missing"; +type BackupAssetKind = "state" | "config" | "credentials" | "workspace" | "agent"; +type BackupSkipReason = "covered" | "missing" | "regenerable" | "unresolved"; export type BackupAsset = { kind: BackupAssetKind; @@ -37,7 +49,7 @@ export type BackupAsset = { }; type SkippedBackupAsset = { - kind: BackupAssetKind; + kind: BackupAssetKind | BackupRegenerableKind | "plugin resources"; sourcePath: string; displayPath: string; reason: BackupSkipReason; @@ -49,6 +61,7 @@ type BackupPlan = { configPath: string; oauthDir: string; workspaceDirs: string[]; + inventory: BackupResourceInventory; included: BackupAsset[]; skipped: SkippedBackupAsset[]; }; @@ -70,6 +83,8 @@ function backupAssetPriority(kind: BackupAssetKind): number { return 2; case "workspace": return 3; + case "agent": + return 4; } throw new Error("Unsupported backup asset kind"); } @@ -131,6 +146,9 @@ async function resolveBackupPlanFromPaths(params: { configPath: string; oauthDir: string; workspaceDirs?: string[]; + agentRoots?: readonly BackupAgentRoot[]; + pluginInventory?: ActivatedPluginBackupInventory; + unresolvedOwnership?: boolean; includeWorkspace?: boolean; onlyConfig?: boolean; configInsideState?: boolean; @@ -144,8 +162,22 @@ async function resolveBackupPlanFromPaths(params: { const oauthDir = params.oauthDir; const archiveRoot = buildBackupArchiveRoot(params.nowMs); const workspaceDirs = includeWorkspace ? (params.workspaceDirs ?? []) : []; + const agentRoots = onlyConfig ? [] : (params.agentRoots ?? []); const configInsideState = params.configInsideState ?? false; const oauthInsideState = params.oauthInsideState ?? false; + const canonicalStateDir = await canonicalizePathForContainment(stateDir); + const inventory = await createBackupResourceInventory({ + stateDir: canonicalStateDir, + configPath: await canonicalizePathForContainment(configPath), + oauthDir: await canonicalizePathForContainment(oauthDir), + workspaceDirs: await Promise.all( + workspaceDirs.map((workspaceDir) => canonicalizePathForContainment(workspaceDir)), + ), + agentRoots, + pluginResources: params.pluginInventory?.resources ?? [], + pluginRoots: params.pluginInventory?.pluginRoots ?? [], + onlyConfig, + }); if (onlyConfig) { const resolvedConfigPath = path.resolve(configPath); @@ -155,6 +187,7 @@ async function resolveBackupPlanFromPaths(params: { configPath, oauthDir, workspaceDirs: [], + inventory, included: [], skipped: [ { @@ -173,6 +206,7 @@ async function resolveBackupPlanFromPaths(params: { configPath, oauthDir, workspaceDirs: [], + inventory, included: [ { kind: "config", @@ -197,6 +231,7 @@ async function resolveBackupPlanFromPaths(params: { kind: "workspace" as const, sourcePath: path.resolve(workspaceDir), })), + ...agentRoots.map((root) => ({ kind: "agent" as const, sourcePath: root.sourcePath })), ]; const candidates: BackupAssetCandidate[] = await Promise.all( @@ -225,6 +260,16 @@ async function resolveBackupPlanFromPaths(params: { for (const candidate of uniqueCandidates) { if (!candidate.exists) { + if ( + candidate.kind === "agent" && + agentRoots.some( + (root) => + root.sourcePath === candidate.canonicalPath && + root.sourcePath === path.join(canonicalStateDir, "agents", root.agentId, "agent"), + ) + ) { + continue; + } skipped.push({ kind: candidate.kind, sourcePath: candidate.sourcePath, @@ -256,11 +301,42 @@ async function resolveBackupPlanFromPaths(params: { }); } + const regenerableRoots = inventory.regenerableRoots.filter( + (resource) => + !inventory.isIncluded(resource.sourcePath) && + included.some((asset) => isPathWithin(resource.sourcePath, asset.sourcePath)), + ); + const regenerableResourceExists = await Promise.all( + regenerableRoots.map((resource) => pathExists(resource.sourcePath)), + ); + for (const [index, resource] of regenerableRoots.entries()) { + if (!regenerableResourceExists[index]) { + continue; + } + skipped.push({ + kind: resource.kind, + sourcePath: resource.sourcePath, + displayPath: shortenHomePath(resource.sourcePath), + reason: "regenerable", + }); + } + if (params.unresolvedOwnership) { + for (const kind of ["agent", "plugin resources"] as const) { + skipped.push({ + kind, + sourcePath: configPath, + displayPath: shortenHomePath(configPath), + reason: "unresolved", + }); + } + } + return { stateDir, configPath, oauthDir, workspaceDirs: workspaceDirs.map((entry) => path.resolve(entry)), + inventory, included, skipped, }; @@ -313,6 +389,26 @@ export async function canonicalizePathForContainment(targetPath: string): Promis } } +/** Resolve one configured agent's canonical backup root and owner database path. */ +export async function resolveBackupAgentRoot( + config: OpenClawConfig, + agentId: string, +): Promise { + const sourcePath = await canonicalizePathForContainment(resolveAgentDir(config, agentId)); + return { + agentId, + sourcePath, + databasePath: path.join(sourcePath, "openclaw-agent.sqlite"), + }; +} + +/** Resolve configured agent storage roots and their canonical database paths for backup ownership. */ +export async function resolveBackupAgentRoots(config: OpenClawConfig): Promise { + return await Promise.all( + listAgentIds(config).map((agentId) => resolveBackupAgentRoot(config, agentId)), + ); +} + /** Resolve the backup plan from the current OpenClaw state/config/workspace paths on disk. */ export async function resolveBackupPlanFromDisk( params: { @@ -327,9 +423,20 @@ export async function resolveBackupPlanFromDisk( const configPath = resolveConfigPath(); const oauthDir = resolveOAuthDir(); + if (onlyConfig) { + return await resolveBackupPlanFromPaths({ + stateDir, + configPath, + oauthDir, + includeWorkspace: false, + onlyConfig: true, + nowMs: params.nowMs, + }); + } + // Backup discovery must not initialize or migrate the state DB before snapshot validation. const configSnapshot = await readConfigFileSnapshot({ observe: false }); - const discoverySnapshot = planUpgradeConfigRepair(configSnapshot)?.snapshot ?? configSnapshot; + const discoverySnapshot = resolveUpgradeConfigSnapshot(configSnapshot) ?? configSnapshot; if (includeWorkspace && discoverySnapshot.exists && !discoverySnapshot.valid) { throw new Error( `Config invalid at ${shortenHomePath(discoverySnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`, @@ -342,11 +449,27 @@ export async function resolveBackupPlanFromDisk( configPath, oauthDir, }); + const unresolvedOwnership = discoverySnapshot.exists && !discoverySnapshot.valid; + const agentRoots = unresolvedOwnership + ? [] + : await resolveBackupAgentRoots(discoverySnapshot.config); + const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : []; + const pluginInventory = unresolvedOwnership + ? undefined + : resolveActivatedPluginBackupInventory({ + config: discoverySnapshot.config, + env: process.env, + stateDir, + workspaceDirs, + }); return await resolveBackupPlanFromPaths({ stateDir, configPath, oauthDir, - workspaceDirs: includeWorkspace ? cleanupPlan.workspaceDirs : [], + workspaceDirs, + agentRoots, + pluginInventory, + unresolvedOwnership, includeWorkspace, onlyConfig, configInsideState: cleanupPlan.configInsideState, diff --git a/src/commands/backup-sqlite.test.ts b/src/commands/backup-sqlite.test.ts index 4744d39038cc..8db963c58469 100644 --- a/src/commands/backup-sqlite.test.ts +++ b/src/commands/backup-sqlite.test.ts @@ -229,30 +229,44 @@ describe("SQLite backup commands", () => { ).rejects.toThrow(missingSnapshotMessage); }); - it("creates a snapshot for a normalized per-agent database", async () => { - const tempDir = tempDirs.make("openclaw-backup-sqlite-"); - const stateDir = path.join(tempDir, "state"); - const repositoryPath = path.join(tempDir, "snapshots"); - process.env.OPENCLAW_STATE_DIR = stateDir; - const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "ops-team" }); - await fs.mkdir(path.dirname(databasePath), { recursive: true }); - createAgentDatabase(databasePath, "ops-team"); - const runtime = createRuntimeCapture(); + it.each([ + { label: "default", customAgentDir: false }, + { label: "configured external", customAgentDir: true }, + ])( + "creates a snapshot for a normalized $label per-agent database", + async ({ customAgentDir }) => { + const tempDir = tempDirs.make("openclaw-backup-sqlite-"); + const stateDir = path.join(tempDir, "state"); + const repositoryPath = path.join(tempDir, "snapshots"); + process.env.OPENCLAW_STATE_DIR = stateDir; + const agentDir = customAgentDir ? path.join(tempDir, "external-agent") : undefined; + if (agentDir) { + configMocks.getRuntimeConfig.mockReturnValue({ + agents: { entries: { "ops-team": { agentDir } } }, + }); + } + const databasePath = agentDir + ? path.join(agentDir, "openclaw-agent.sqlite") + : resolveOpenClawAgentSqlitePath({ agentId: "ops-team" }); + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + createAgentDatabase(databasePath, "ops-team"); + const runtime = createRuntimeCapture(); - const created = await backupSqliteCreateCommand(runtime, { - agent: "Ops Team", - repository: repositoryPath, - }); + const created = await backupSqliteCreateCommand(runtime, { + agent: "Ops Team", + repository: repositoryPath, + }); - expect(created.manifest.database).toEqual({ - role: "agent", - agentId: "ops-team", - basename: "openclaw-agent.sqlite", - userVersion: OPENCLAW_AGENT_SCHEMA_VERSION, - }); - expect(runtime.logs).toEqual([expect.stringContaining("Database: agent:ops-team")]); - expect(runtime.errors).toEqual([]); - }); + expect(created.manifest.database).toEqual({ + role: "agent", + agentId: "ops-team", + basename: "openclaw-agent.sqlite", + userVersion: OPENCLAW_AGENT_SCHEMA_VERSION, + }); + expect(runtime.logs).toEqual([expect.stringContaining("Database: agent:ops-team")]); + expect(runtime.errors).toEqual([]); + }, + ); it("requires exactly one named OpenClaw database source", async () => { const runtime = createRuntimeCapture(); diff --git a/src/commands/backup-sqlite.ts b/src/commands/backup-sqlite.ts index ca65f9fb5933..37b9b30ca0dd 100644 --- a/src/commands/backup-sqlite.ts +++ b/src/commands/backup-sqlite.ts @@ -13,10 +13,9 @@ import type { SnapshotSummary, } from "../snapshot/snapshot-provider.js"; import { recordBackupRunOutcome } from "../state/backup-run-records.js"; -import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { shortenHomePath } from "../utils.js"; -import { resolveRequiredBackupPath } from "./backup-shared.js"; +import { resolveBackupAgentRoot, resolveRequiredBackupPath } from "./backup-shared.js"; type BackupSqliteCreateOptions = { global?: boolean; @@ -186,12 +185,11 @@ async function resolveSnapshotDatabase( identity: { role: "global" }, }; } - const agentId = resolveConfiguredAgentId( - getRuntimeConfig({ skipPluginValidation: true }), - normalizeAgentId(rawAgentId), - ); + const config = getRuntimeConfig({ skipPluginValidation: true }); + const agentId = resolveConfiguredAgentId(config, normalizeAgentId(rawAgentId)); + const agentRoot = await resolveBackupAgentRoot(config, agentId); return { - path: await fs.realpath(resolveOpenClawAgentSqlitePath({ agentId })), + path: await fs.realpath(agentRoot.databasePath), identity: { role: "agent", agentId }, }; } diff --git a/src/commands/backup-verify-manifest.ts b/src/commands/backup-verify-manifest.ts index 7286f0ae74d3..14452371cb83 100644 --- a/src/commands/backup-verify-manifest.ts +++ b/src/commands/backup-verify-manifest.ts @@ -1,10 +1,11 @@ import path from "node:path"; -import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { normalizeAgentId } from "@openclaw/normalization-core/agent-id"; import { isArchivePathWithin, normalizeArchivePath, normalizeArchiveRoot, } from "../infra/backup-archive-path-policy.js"; +import { normalizeWindowsPathForComparison } from "../infra/path-guards.js"; import { isRecord } from "../utils.js"; export type BackupManifest = { @@ -16,12 +17,14 @@ export type BackupManifest = { nodeVersion: string; options?: { includeWorkspace?: boolean; + onlyConfig?: boolean; }; paths?: { stateDir?: string; configPath?: string; oauthDir?: string; workspaceDirs?: string[]; + agentRoots?: Array<{ agentId: string; sourcePath: string }>; }; assets: Array<{ kind: string; @@ -36,6 +39,59 @@ export type BackupManifest = { }>; }; +function parseBackupManifestSourcePath(value: unknown, label: string): string { + if (typeof value !== "string" || value.includes("\0")) { + throw new Error(`Backup manifest ${label} has an invalid sourcePath.`); + } + const windowsPath = /^[A-Za-z]:[\\/]/u.test(value); + const normalized = windowsPath ? path.win32.normalize(value) : path.posix.normalize(value); + if ((!windowsPath && !value.startsWith("/")) || normalized !== value) { + throw new Error(`Backup manifest ${label} sourcePath must be absolute and normalized.`); + } + return value; +} + +function parseBackupManifestAgentRoots( + value: unknown, +): Array<{ agentId: string; sourcePath: string }> | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error("Backup manifest agentRoots must be an array."); + } + + const agentRoots: Array<{ agentId: string; sourcePath: string }> = []; + const seenAgentIds = new Set(); + const seenSourcePaths = new Set(); + for (const agentRoot of value) { + if ( + !isRecord(agentRoot) || + Object.keys(agentRoot).length !== 2 || + !Object.hasOwn(agentRoot, "agentId") || + !Object.hasOwn(agentRoot, "sourcePath") + ) { + throw new Error("Backup manifest agent root must contain only agentId and sourcePath."); + } + const { agentId, sourcePath } = agentRoot; + if (typeof agentId !== "string" || !agentId || normalizeAgentId(agentId) !== agentId) { + throw new Error("Backup manifest agent root has an invalid or noncanonical agentId."); + } + const normalizedSourcePath = parseBackupManifestSourcePath(sourcePath, "agent root"); + const windowsPath = /^[A-Za-z]:[\\/]/u.test(normalizedSourcePath); + const sourcePathKey = windowsPath + ? normalizeWindowsPathForComparison(normalizedSourcePath) + : normalizedSourcePath; + if (seenAgentIds.has(agentId) || seenSourcePaths.has(sourcePathKey)) { + throw new Error("Backup manifest contains duplicate agent root ownership."); + } + seenAgentIds.add(agentId); + seenSourcePaths.add(sourcePathKey); + agentRoots.push({ agentId, sourcePath: normalizedSourcePath }); + } + return agentRoots; +} + export function parseBackupManifest(raw: string): BackupManifest { let parsed: unknown; try { @@ -91,23 +147,17 @@ export function parseBackupManifest(raw: string): BackupManifest { : "unknown", platform: typeof parsed.platform === "string" ? parsed.platform : "unknown", nodeVersion: typeof parsed.nodeVersion === "string" ? parsed.nodeVersion : "unknown", - options: isRecord(parsed.options) - ? { includeWorkspace: parsed.options.includeWorkspace as boolean | undefined } - : undefined, paths: isRecord(parsed.paths) ? { - stateDir: readStringValue(parsed.paths.stateDir), - configPath: readStringValue(parsed.paths.configPath), - oauthDir: readStringValue(parsed.paths.oauthDir), - workspaceDirs: Array.isArray(parsed.paths.workspaceDirs) - ? parsed.paths.workspaceDirs.filter( - (entry): entry is string => typeof entry === "string", - ) - : undefined, + ...(parsed.paths.stateDir === undefined + ? {} + : { + stateDir: parseBackupManifestSourcePath(parsed.paths.stateDir, "state directory"), + }), + agentRoots: parseBackupManifestAgentRoots(parsed.paths.agentRoots), } : undefined, assets, - skipped: Array.isArray(parsed.skipped) ? parsed.skipped : undefined, }; } diff --git a/src/commands/backup-verify.test.ts b/src/commands/backup-verify.test.ts index 487999dec915..d695f8d2dd2e 100644 --- a/src/commands/backup-verify.test.ts +++ b/src/commands/backup-verify.test.ts @@ -10,7 +10,8 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { runCommandWithRuntime } from "../cli/cli-utils.js"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { buildBackupArchivePath, buildBackupArchiveRoot } from "./backup-shared.js"; -import { backupVerifyCommand, testApi } from "./backup-verify.js"; +import type { BackupManifest } from "./backup-verify-manifest.js"; +import { backupVerifyCommand, testApi, verifyBackupArchive } from "./backup-verify.js"; const TEST_ARCHIVE_ROOT = "2026-03-09T00-00-00.000Z-openclaw-backup"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -25,7 +26,7 @@ function createBackupManifest( assetArchivePath: string, archiveRoot = TEST_ARCHIVE_ROOT, stateDir = "/tmp/.openclaw", -) { +): BackupManifest { return { schemaVersion: 1, createdAt: "2026-03-09T00:00:00.000Z", @@ -319,6 +320,192 @@ describe("backupVerifyCommand", () => { ); }); + it.each([ + { + name: "standalone agent asset", + agentDir: "/tmp/custom-agent", + coveringAsset: { kind: "agent", sourcePath: "/tmp/custom-agent" }, + }, + { + name: "external workspace asset", + agentDir: "/tmp/workspace/custom-agent", + coveringAsset: { kind: "workspace", sourcePath: "/tmp/workspace" }, + }, + { + name: "managed state dependency path", + agentDir: "/tmp/.openclaw/node_modules/custom-agent", + coveringAsset: undefined, + }, + ])("verifies the declared agent owner beneath a $name", async ({ agentDir, coveringAsset }) => { + const stateAssetArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/.openclaw"); + const agentArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, agentDir); + const manifest = { + ...createBackupManifest(stateAssetArchivePath), + paths: { + stateDir: "/tmp/.openclaw", + agentRoots: [{ agentId: "main", sourcePath: agentDir }], + }, + assets: [ + ...createBackupManifest(stateAssetArchivePath).assets, + ...(coveringAsset + ? [ + { + ...coveringAsset, + archivePath: buildBackupArchivePath(TEST_ARCHIVE_ROOT, coveringAsset.sourcePath), + }, + ] + : []), + ], + }; + const sqlitePayload = await createSqlitePayload((database) => { + database.exec(` + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT + ); + INSERT INTO schema_meta (meta_key, role, schema_version, agent_id) + VALUES ('primary', 'agent', 1, 'main'); + `); + }); + + await withBrokenArchiveFixture( + { + tempPrefix: "openclaw-backup-custom-agent-sqlite-", + manifestAssetArchivePath: stateAssetArchivePath, + manifest, + payloads: [ + { + fileName: "state.txt", + contents: "state\n", + archivePath: `${stateAssetArchivePath}/state.txt`, + }, + { + fileName: "openclaw-agent.sqlite", + contents: sqlitePayload, + archivePath: `${agentArchivePath}/openclaw-agent.sqlite`, + }, + ...(coveringAsset?.kind === "workspace" + ? [ + { + fileName: "workspace.sqlite", + contents: "workspace SQLite files retain raw-file semantics", + archivePath: `${buildBackupArchivePath(TEST_ARCHIVE_ROOT, coveringAsset.sourcePath)}/workspace.sqlite`, + }, + ] + : []), + ], + }, + async (archivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).resolves.toMatchObject( + { ok: true }, + ); + }, + ); + }); + + it.each([ + { + name: "wrong role", + role: "global", + agentId: "main", + error: /has schema role global; expected agent/iu, + }, + { + name: "wrong agent owner", + role: "agent", + agentId: "worker", + error: /belongs to agent worker; requested agent main/iu, + }, + ])("rejects a custom agent SQLite snapshot with the $name", async ({ role, agentId, error }) => { + const stateAssetArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/.openclaw"); + const agentDir = "/tmp/workspace/custom-agent"; + const workspaceArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/workspace"); + const manifest = { + ...createBackupManifest(stateAssetArchivePath), + paths: { + stateDir: "/tmp/.openclaw", + agentRoots: [{ agentId: "main", sourcePath: agentDir }], + }, + assets: [ + ...createBackupManifest(stateAssetArchivePath).assets, + { kind: "workspace", sourcePath: "/tmp/workspace", archivePath: workspaceArchivePath }, + ], + }; + const sqlitePayload = await createSqlitePayload((database) => { + database.exec(` + CREATE TABLE schema_meta ( + meta_key TEXT NOT NULL PRIMARY KEY, + role TEXT NOT NULL, + schema_version INTEGER NOT NULL, + agent_id TEXT + ); + `); + database + .prepare( + "INSERT INTO schema_meta (meta_key, role, schema_version, agent_id) VALUES ('primary', ?, 1, ?)", + ) + .run(role, agentId); + }); + + await withBrokenArchiveFixture( + { + tempPrefix: "openclaw-backup-custom-agent-owner-", + manifestAssetArchivePath: stateAssetArchivePath, + manifest, + payloads: [ + { + fileName: "state.txt", + contents: "state\n", + archivePath: `${stateAssetArchivePath}/state.txt`, + }, + { + fileName: "openclaw-agent.sqlite", + contents: sqlitePayload, + archivePath: `${buildBackupArchivePath(TEST_ARCHIVE_ROOT, agentDir)}/openclaw-agent.sqlite`, + }, + ], + }, + async (archivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow(error); + }, + ); + }); + + it("preserves verification compatibility for a default-layout agent without ownership metadata", async () => { + const stateAssetArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/.openclaw"); + const sqlitePayload = await createSqlitePayload((database) => { + database.exec(` + CREATE TABLE schema_meta (meta_key TEXT NOT NULL PRIMARY KEY, role TEXT NOT NULL); + INSERT INTO schema_meta (meta_key, role) VALUES ('primary', 'agent'); + `); + }); + + await withBrokenArchiveFixture( + { + tempPrefix: "openclaw-backup-legacy-agent-sqlite-", + manifestAssetArchivePath: stateAssetArchivePath, + manifest: { ...createBackupManifest(stateAssetArchivePath), paths: undefined }, + payloads: [ + { + fileName: "openclaw-agent.sqlite", + contents: sqlitePayload, + archivePath: `${stateAssetArchivePath}/agents/main/agent/openclaw-agent.sqlite`, + }, + ], + }, + async (archivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).resolves.toMatchObject( + { ok: true }, + ); + }, + ); + }); + it.runIf(process.platform === "win32")( "verifies a canonical global SQLite backup beyond MAX_PATH", async () => { @@ -557,6 +744,53 @@ describe("backupVerifyCommand", () => { }, ); + it("rejects custom-agent SQLite sidecars covered by a workspace asset", async () => { + const stateAssetArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/.openclaw"); + const agentDir = "/tmp/workspace/custom-agent"; + const agentArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, agentDir); + const manifest = { + ...createBackupManifest(stateAssetArchivePath), + paths: { + stateDir: "/tmp/.openclaw", + agentRoots: [{ agentId: "main", sourcePath: agentDir }], + }, + assets: [ + ...createBackupManifest(stateAssetArchivePath).assets, + { + kind: "workspace", + sourcePath: "/tmp/workspace", + archivePath: buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/workspace"), + }, + ], + }; + + await withBrokenArchiveFixture( + { + tempPrefix: "openclaw-backup-custom-agent-sidecar-", + manifestAssetArchivePath: stateAssetArchivePath, + manifest, + payloads: [ + { + fileName: "state.txt", + contents: "state\n", + archivePath: `${stateAssetArchivePath}/state.txt`, + }, + { + fileName: "openclaw-agent.sqlite-wal", + contents: "unverified transaction data", + archivePath: `${agentArchivePath}/openclaw-agent.sqlite-wal`, + }, + ], + }, + async (archivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow( + /contains a SQLite snapshot sidecar.*openclaw-agent\.sqlite-wal/iu, + ); + }, + ); + }); + it("rejects case-mangled canonical SQLite paths", async () => { const stateAssetArchivePath = `${TEST_ARCHIVE_ROOT}/payload/posix/tmp/.openclaw`; const sqliteArchivePath = `${stateAssetArchivePath}/State/OpenClaw.SQLITE`; @@ -736,6 +970,63 @@ describe("backupVerifyCommand", () => { ); }); + it.each([ + { + name: "wrong database role", + schema: ` + CREATE TABLE schema_meta (meta_key TEXT NOT NULL PRIMARY KEY, role TEXT NOT NULL); + INSERT INTO schema_meta (meta_key, role) VALUES ('primary', 'agent'); + `, + error: /has role agent; expected global/iu, + }, + { + name: "foreign-key corruption", + schema: ` + PRAGMA foreign_keys = OFF; + CREATE TABLE schema_meta (meta_key TEXT NOT NULL PRIMARY KEY, role TEXT NOT NULL); + INSERT INTO schema_meta (meta_key, role) VALUES ('primary', 'global'); + CREATE TABLE parents (id INTEGER PRIMARY KEY); + CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parents(id)); + INSERT INTO children (id, parent_id) VALUES (1, 99); + `, + error: /foreign_key_check failed/iu, + }, + ])( + "rejects $name in the global database covered by an enclosing agent root", + async ({ schema, error }) => { + const agentDir = "/tmp/enclosing-agent"; + const stateDir = `${agentDir}/.openclaw`; + const agentArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, agentDir); + const manifest = { + ...createBackupManifest(agentArchivePath, TEST_ARCHIVE_ROOT, stateDir), + paths: { + stateDir, + agentRoots: [{ agentId: "main", sourcePath: agentDir }], + }, + assets: [{ kind: "agent", sourcePath: agentDir, archivePath: agentArchivePath }], + }; + const sqlitePayload = await createSqlitePayload((database) => database.exec(schema)); + + await withBrokenArchiveFixture( + { + tempPrefix: "openclaw-backup-enclosed-state-sqlite-", + manifestAssetArchivePath: agentArchivePath, + manifest, + payloads: [ + { + fileName: "openclaw.sqlite", + contents: sqlitePayload, + archivePath: `${buildBackupArchivePath(TEST_ARCHIVE_ROOT, stateDir)}/state/openclaw.sqlite`, + }, + ], + }, + async (archivePath) => { + await expect(verifyBackupArchive(archivePath)).rejects.toThrow(error); + }, + ); + }, + ); + it("validates a canonical agent database whose agent id is node_modules", async () => { const stateAssetArchivePath = `${TEST_ARCHIVE_ROOT}/payload/posix/tmp/.openclaw`; const sqliteArchivePath = `${stateAssetArchivePath}/agents/node_modules/agent/openclaw-agent.sqlite`; @@ -982,6 +1273,54 @@ describe("backupVerifyCommand", () => { ); }); + it.each([ + { name: "non-array roots", agentRoots: {}, error: /agentRoots must be an array/u }, + { + name: "an extra ownership field", + agentRoots: [{ agentId: "main", sourcePath: "/tmp/agent", owner: "other" }], + error: /must contain only agentId and sourcePath/u, + }, + { + name: "a noncanonical agent id", + agentRoots: [{ agentId: "Main", sourcePath: "/tmp/agent" }], + error: /invalid or noncanonical agentId/u, + }, + { + name: "a relative agent path", + agentRoots: [{ agentId: "main", sourcePath: "../agent" }], + error: /must be absolute and normalized/u, + }, + { + name: "a noncanonical agent path", + agentRoots: [{ agentId: "main", sourcePath: "/tmp/agent/../other" }], + error: /must be absolute and normalized/u, + }, + { + name: "duplicate agent ownership", + agentRoots: [ + { agentId: "main", sourcePath: "/tmp/agent-one" }, + { agentId: "main", sourcePath: "/tmp/agent-two" }, + ], + error: /duplicate agent root ownership/u, + }, + ])("rejects $name in backup agent ownership metadata", async ({ agentRoots, error }) => { + const stateAssetArchivePath = buildBackupArchivePath(TEST_ARCHIVE_ROOT, "/tmp/.openclaw"); + const manifest = { + ...createBackupManifest(stateAssetArchivePath), + paths: { stateDir: "/tmp/.openclaw", agentRoots }, + }; + await createArchiveWithManifestContent( + { + tempPrefix: "openclaw-backup-invalid-agent-roots-", + manifestContent: JSON.stringify(manifest), + }, + async (archivePath) => { + const runtime = createBackupVerifyRuntime(); + await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow(error); + }, + ); + }); + it("rejects oversized manifest entries without retaining the full body", async () => { await createArchiveWithManifestContent( { diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts index f9c45d71bfd9..ca026827a330 100644 --- a/src/commands/backup-verify.ts +++ b/src/commands/backup-verify.ts @@ -16,8 +16,10 @@ import { isTransientSqliteBackupPath } from "../infra/backup-volatile-filter.js" import { formatDiskSpaceBytes, tryReadDiskSpace } from "../infra/disk-space.js"; import { formatErrorMessage, hasErrnoCode } from "../infra/errors.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; +import { SQLITE_SIDECAR_SUFFIXES } from "../infra/sqlite-files.js"; import { assertSqliteIntegrity } from "../infra/sqlite-integrity.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { assertOpenClawAgentDatabaseOwner } from "../state/openclaw-agent-db-maintenance.js"; import { resolveUserPath } from "../utils.js"; import { BACKUP_MAX_DECOMPRESSION_RATIO, buildBackupArchivePath } from "./backup-shared.js"; import { @@ -30,7 +32,6 @@ import { const MAX_MANIFEST_BYTES = 1024 * 1024; const MAX_SQLITE_SNAPSHOT_EXTRACT_BYTES = 64 * 1024 * 1024 * 1024; const SQLITE_SNAPSHOT_FREE_SPACE_RESERVE_BYTES = 256 * 1024 * 1024; -const SQLITE_SNAPSHOT_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"] as const; type BackupVerifyOptions = { archive: string; @@ -64,6 +65,7 @@ type NormalizedArchiveEntry = { type SqliteSnapshotEntry = NormalizedArchiveEntry & { stateAssetRoot: string; + agentId?: string; }; type ExpectedSqliteRole = "agent" | "global"; @@ -240,7 +242,7 @@ function isSqliteSnapshotRelativePath(relativePath: string): boolean { function resolveSqliteSnapshotSidecarDatabasePath(relativePath: string): string | undefined { const portablePath = resolvePortableArchivePathKey(relativePath); - for (const suffix of SQLITE_SNAPSHOT_SIDECAR_SUFFIXES) { + for (const suffix of SQLITE_SIDECAR_SUFFIXES) { if (portablePath.endsWith(suffix)) { const databasePath = relativePath.slice(0, -suffix.length); return isSqliteSnapshotRelativePath(databasePath) ? databasePath : undefined; @@ -272,76 +274,94 @@ function listSqliteSnapshotEntries( manifest: BackupManifest, entries: NormalizedArchiveEntry[], ): SqliteSnapshotEntry[] { - const declaredStateAssetRoots = manifest.assets - .filter((asset) => asset.kind === "state") - .map((asset) => normalizeArchivePath(asset.archivePath, "Backup manifest state asset path")); - for (const root of declaredStateAssetRoots) { - const portableRoot = resolvePortableArchivePathKey(root); - for (const entry of entries) { - const isExactStateEntry = isArchivePathWithin(entry.normalized, root); - const isPortableStateEntry = isArchivePathWithin( - resolvePortableArchivePathKey(entry.normalized), - portableRoot, - ); - if (isPortableStateEntry && !isExactStateEntry) { - throw new Error(`Backup contains a case-mangled state asset path: ${entry.normalized}`); - } - } - } - - const hasSqliteCandidate = entries.some((entry) => - declaredStateAssetRoots.some((root) => { - if (!isArchivePathWithin(entry.normalized, root)) { - return false; - } - const relativePath = path.posix.relative(root, entry.normalized); - return ( - isSqliteSnapshotRelativePath(relativePath) || - resolveSqliteSnapshotSidecarDatabasePath(relativePath) !== undefined - ); - }), - ); - if (!hasSqliteCandidate) { - return []; - } - - const stateAssetRoot = resolveCanonicalStateAssetRoot(manifest); - if (!stateAssetRoot) { - return []; - } + const archiveRoot = normalizeArchiveRoot(manifest.archiveRoot); + const roots = [ + ...(manifest.paths?.stateDir + ? [ + { + kind: "state" as const, + archiveRoot: buildBackupArchivePath(archiveRoot, manifest.paths.stateDir), + }, + ] + : manifest.assets + .filter((asset) => asset.kind === "state") + .map((asset) => ({ + kind: "state" as const, + archiveRoot: normalizeArchivePath( + asset.archivePath, + "Backup manifest state asset path", + ), + }))), + ...(manifest.paths?.agentRoots ?? []).map(({ agentId, sourcePath }) => ({ + kind: "agent" as const, + archiveRoot: buildBackupArchivePath(archiveRoot, sourcePath), + agentId, + })), + ] + .map((root) => + Object.assign(root, { + portableArchiveRoot: resolvePortableArchivePathKey(root.archiveRoot), + }), + ) + .toSorted((left, right) => right.archiveRoot.length - left.archiveRoot.length); + const sqliteEntries: SqliteSnapshotEntry[] = []; for (const entry of entries) { - if (!isArchivePathWithin(entry.normalized, stateAssetRoot)) { + const portableEntryPath = resolvePortableArchivePathKey(entry.normalized); + const portableRoot = roots.find((root) => + isArchivePathWithin(portableEntryPath, root.portableArchiveRoot), + ); + const sqliteRoot = roots.find((root) => + isArchivePathWithin(entry.normalized, root.archiveRoot), + ); + if (portableRoot && portableRoot !== sqliteRoot) { + throw new Error( + `Backup contains a case-mangled ${portableRoot.kind} asset path: ${entry.normalized}`, + ); + } + if (!sqliteRoot) { continue; } - const relativePath = path.posix.relative(stateAssetRoot, entry.normalized); + + const relativePath = path.posix.relative(sqliteRoot.archiveRoot, entry.normalized); assertCanonicalSqlitePathCasing(relativePath, entry.normalized); + if ( + sqliteRoot.kind === "agent" && + resolvePortableArchivePathKey(relativePath) === "openclaw-agent.sqlite" && + relativePath !== "openclaw-agent.sqlite" + ) { + throw new Error(`Backup contains a case-mangled canonical SQLite path: ${entry.normalized}`); + } if (resolveSqliteSnapshotSidecarDatabasePath(relativePath)) { throw new Error(`Backup contains a SQLite snapshot sidecar: ${entry.normalized}`); } - } - - return entries.flatMap((entry) => { - if (!isArchivePathWithin(entry.normalized, stateAssetRoot)) { - return []; - } - const relativePath = path.posix.relative(stateAssetRoot, entry.normalized); // Only state-owned database snapshots should be opened during verification. // Package content, excluded reindex artifacts, and noncanonical symlinks are // preserved or skipped by backup creation without becoming SQLite snapshots. if (!isSqliteSnapshotRelativePath(relativePath)) { - return []; + continue; } - const candidate = { ...entry, stateAssetRoot }; - if (!resolveExpectedSqliteRole(candidate) && !isRegularArchiveFile(entry.type)) { - return []; + const candidate: SqliteSnapshotEntry = { + ...entry, + stateAssetRoot: sqliteRoot.archiveRoot, + ...(sqliteRoot.kind === "agent" ? { agentId: sqliteRoot.agentId } : {}), + }; + if (resolveExpectedSqliteRole(candidate) || isRegularArchiveFile(entry.type)) { + sqliteEntries.push(candidate); } - return [candidate]; - }); + } + + if (sqliteEntries.length > 0) { + resolveCanonicalStateAssetRoot(manifest); + } + return sqliteEntries; } function resolveExpectedSqliteRole(entry: SqliteSnapshotEntry): ExpectedSqliteRole | undefined { const relativePath = path.posix.relative(entry.stateAssetRoot, entry.normalized); + if (entry.agentId) { + return relativePath === "openclaw-agent.sqlite" ? "agent" : undefined; + } return resolveExpectedSqliteRoleFromRelativePath(relativePath); } @@ -530,7 +550,14 @@ async function verifySqliteSnapshots(params: { database.exec("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;"); await loadSqliteVecExtension({ db: database }); assertSqliteIntegrity(database, entry.normalized); - assertExpectedSqliteRole(database, entry.normalized, expectedRole); + if (entry.agentId) { + assertOpenClawAgentDatabaseOwner(database, { + agentId: entry.agentId, + pathname: entry.normalized, + }); + } else { + assertExpectedSqliteRole(database, entry.normalized, expectedRole); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error( diff --git a/src/commands/backup.test-support.ts b/src/commands/backup.test-support.ts index 84d6245f6f56..e6f0bbd8b465 100644 --- a/src/commands/backup.test-support.ts +++ b/src/commands/backup.test-support.ts @@ -6,22 +6,8 @@ import { vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { deleteTestEnvValue } from "../test-utils/env.js"; import * as backupShared from "./backup-shared.js"; -import type { BackupAsset } from "./backup-shared.js"; -type BackupPlan = { - stateDir: string; - configPath: string; - oauthDir: string; - workspaceDirs: string[]; - included: BackupAsset[]; - skipped: Array<{ - kind: "state" | "config" | "credentials" | "workspace"; - sourcePath: string; - displayPath: string; - reason: "covered" | "missing"; - coveredBy?: string; - }>; -}; +type BackupPlan = Awaited>; type ResolveBackupPlanFromPathsParams = { stateDir: string; diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index 61c47311a71a..ff5497ff2bd9 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -41,6 +41,7 @@ type CapturedBackupManifest = { configPath: string; oauthDir: string; workspaceDirs: string[]; + agentRoots: Array<{ agentId: string; sourcePath: string }>; }; assets: Array>; skipped: Array<{ kind: string; sourcePath: string; reason: string; coveredBy?: string }>; @@ -310,6 +311,7 @@ describe("backup commands", () => { configPath, oauthDir: path.join(stateDir, "credentials"), workspaceDirs: [externalWorkspace], + agentRoots: [], }); expect(manifest.assets).toEqual( result.assets.map((asset) => ({ @@ -364,7 +366,12 @@ describe("backup commands", () => { const runtime = createBackupTestRuntime(); await mockStateOnlyBackupPlan(stateDir); tarCreateMock.mockImplementationOnce( - (options: { filter?: (entryPath: string) => boolean }, entryPaths: string[]) => + ( + options: { + filter?: (entryPath: string, entryStat: { isDirectory: () => boolean }) => boolean; + }, + entryPaths: string[], + ) => createMockTarStream({ beforeRead: () => { const manifestPath = entryPaths[0]; @@ -372,9 +379,13 @@ describe("backup commands", () => { if (!manifestPath || !stateRoot) { throw new Error("backup test expected manifest and state entries"); } - expect(options.filter?.(manifestPath)).toBe(true); + const fileStat = { isDirectory: () => false }; + expect(options.filter?.(manifestPath, fileStat)).toBe(true); expect( - options.filter?.(path.join(stateRoot, "agents", "main", "sessions", "s.jsonl")), + options.filter?.( + path.join(stateRoot, "agents", "main", "sessions", "s.jsonl"), + fileStat, + ), ).toBe(false); }, }), diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 5539eda90477..a366909f1f86 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -7,6 +7,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import * as tar from "tar"; import { describe, expect, it, vi } from "vitest"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import { backupRestoreCommand } from "../commands/backup-restore.js"; import { backupVerifyCommand } from "../commands/backup-verify.js"; import { CONFIG_AUDIT_MAX_ENTRIES, CONFIG_AUDIT_SCOPE } from "../config/io.audit.js"; import { resolveGatewayLockDir } from "../config/paths.js"; @@ -605,6 +606,320 @@ describe("createBackupVolatileStatCache", () => { }); describe("createBackupArchive", () => { + it("includes a configured external agent directory when workspaces are excluded", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-external-agent-", + scenario: "minimal", + }, + async (state) => { + const agentDir = path.join(await fs.realpath(state.root), "external-agent"); + const pluginSkillsDir = state.statePath("plugin-skills"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.mkdir(pluginSkillsDir, { recursive: true }); + await fs.writeFile(path.join(agentDir, "durable-agent-state.json"), "{}\n", "utf8"); + await fs.writeFile(path.join(pluginSkillsDir, "generated-skill.md"), "generated\n", "utf8"); + await state.writeConfig({ + agents: { + entries: { main: { default: true, agentDir } }, + }, + }); + + const archive = await createBackupArchive({ + output: state.path("backup.tar.gz"), + includeWorkspace: false, + }); + expect(archive.assets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "agent", sourcePath: agentDir }), + ]), + ); + const entries = await listArchiveEntries(archive.archivePath); + expect( + entries.some((entry) => entry.endsWith("/external-agent/durable-agent-state.json")), + ).toBe(true); + expect(entries.some((entry) => entry.includes("/plugin-skills/"))).toBe(false); + + const extractDir = state.path("manifest-extract"); + await fs.mkdir(extractDir, { recursive: true }); + await tar.x({ file: archive.archivePath, gzip: true, cwd: extractDir }); + const manifest = JSON.parse( + await fs.readFile(path.join(extractDir, archive.archiveRoot, "manifest.json"), "utf8"), + ) as { + paths: { agentRoots: Array<{ agentId: string; sourcePath: string }> }; + skipped: Array>; + }; + expect(manifest.paths.agentRoots).toContainEqual({ agentId: "main", sourcePath: agentDir }); + expect(manifest.skipped).toEqual( + archive.skipped.map(({ kind, sourcePath, reason, coveredBy }) => + Object.assign({ kind, sourcePath, reason }, coveredBy ? { coveredBy } : {}), + ), + ); + }, + ); + }); + + it.each([ + { name: "external agent asset", placement: "external", includeWorkspace: false }, + { name: "agent covered by a workspace", placement: "workspace", includeWorkspace: true }, + { name: "agent under a managed state root", placement: "managed", includeWorkspace: false }, + { + name: "custom agent nested in the default agent layout", + placement: "default-layout", + includeWorkspace: false, + }, + ] as const)( + "safely snapshots, verifies, and restores a configured $name", + async ({ placement, includeWorkspace }) => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-owned-agent-sqlite-", + scenario: "minimal", + }, + async (state) => { + const agentDir = + placement === "workspace" + ? path.join(state.workspaceDir, "custom-agent") + : placement === "managed" + ? state.statePath("tmp", "custom-agent") + : placement === "default-layout" + ? state.statePath("agents", "main", "agent", "custom-agent") + : state.path("custom-agent"); + const dbPath = path.join(agentDir, "openclaw-agent.sqlite"); + const durableAgentDirectories = [ + "tmp", + ".tmp", + "runtime-home/tmp", + "runtime-home/.tmp", + "tmp-data", + ".tmp-data", + ]; + await fs.mkdir(agentDir, { recursive: true }); + await state.writeConfig({ + agents: { + entries: { + main: { + default: true, + agentDir, + ...(includeWorkspace ? { workspace: state.workspaceDir } : {}), + }, + }, + }, + }); + for (const dirname of durableAgentDirectories) { + await fs.mkdir(path.join(agentDir, dirname), { recursive: true }); + await fs.writeFile(path.join(agentDir, dirname, "durable.txt"), "keep\n", "utf8"); + } + createOwnedSqliteDatabase({ sqlitePath: dbPath, role: "agent", agentId: "main" }); + + const sqlite = requireNodeSqlite(); + const db = new sqlite.DatabaseSync(dbPath); + const deletedMarker = "EXTERNAL_AGENT_DELETED_SECRET_84b5f1"; + let archive: BackupCreateResult; + try { + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA wal_autocheckpoint = 0; + PRAGMA secure_delete = OFF; + CREATE TABLE durable_records (value TEXT NOT NULL); + `); + db.prepare("INSERT INTO durable_records (value) VALUES (?)").run( + `${deletedMarker}-${"x".repeat(16_384)}`, + ); + db.prepare("INSERT INTO durable_records (value) VALUES (?)").run("checkpointed"); + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + db.prepare("DELETE FROM durable_records WHERE value LIKE ?").run(`${deletedMarker}%`); + db.prepare("INSERT INTO durable_records (value) VALUES (?)").run("committed-in-wal"); + expect((await fs.readFile(dbPath)).includes(Buffer.from(deletedMarker))).toBe(true); + await expect(fs.access(`${dbPath}-wal`)).resolves.toBeUndefined(); + + archive = await createBackupArchive({ + output: state.path("owned-agent.tar.gz"), + includeWorkspace, + }); + } finally { + db.close(); + } + + const entries = await listArchiveEntries(archive.archivePath); + const archivedDbEntry = expectDefined( + entries.find((entry) => entry.endsWith("/custom-agent/openclaw-agent.sqlite")), + "configured agent database snapshot", + ); + expect(entries.some((entry) => entry.endsWith("/openclaw-agent.sqlite-wal"))).toBe(false); + expect(entries.some((entry) => entry.endsWith("/openclaw-agent.sqlite-shm"))).toBe(false); + for (const dirname of durableAgentDirectories) { + expect( + entries.some((entry) => entry.endsWith(`/custom-agent/${dirname}/durable.txt`)), + ).toBe(true); + } + + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + const restore = await backupRestoreCommand(runtime, { + archive: archive.archivePath, + target: state.path("restored"), + }); + const restoredDbPath = path.join(restore.targetPath, archivedDbEntry); + expect((await fs.readFile(restoredDbPath)).includes(Buffer.from(deletedMarker))).toBe( + false, + ); + const restoredDb = new sqlite.DatabaseSync(restoredDbPath, { readOnly: true }); + try { + expect( + restoredDb.prepare("SELECT value FROM durable_records ORDER BY value").all(), + ).toEqual([{ value: "checkpointed" }, { value: "committed-in-wal" }]); + } finally { + restoredDb.close(); + } + }, + ); + }, + ); + + it("rejects a configured external agent database owned by a different agent", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-external-agent-owner-", + scenario: "minimal", + }, + async (state) => { + const agentDir = state.path("external-agent"); + await fs.mkdir(agentDir, { recursive: true }); + await state.writeConfig({ agents: { entries: { main: { default: true, agentDir } } } }); + createOwnedSqliteDatabase({ + sqlitePath: path.join(agentDir, "openclaw-agent.sqlite"), + role: "agent", + agentId: "other", + }); + + await expect( + createBackupArchive({ output: state.path("rejected.tar.gz"), includeWorkspace: false }), + ).rejects.toThrow(/belongs to agent other; requested agent main/iu); + }, + ); + }); + + it("applies activated manifest-owned exclusions before SQLite and symlink handling", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-plugin-resource-", + scenario: "minimal", + }, + async (state) => { + const agentDir = state.path("external-agent"); + const pluginRoot = state.path("synthetic-backup-plugin"); + const excludedStateRoot = state.statePath("generated"); + const excludedAgentRoot = path.join(agentDir, "codex-home", "tmp", "arg0"); + await fs.mkdir(pluginRoot, { recursive: true }); + await fs.mkdir(path.join(excludedStateRoot, "protected"), { recursive: true }); + await fs.mkdir(path.join(excludedAgentRoot, "protected"), { recursive: true }); + await fs.mkdir(path.join(agentDir, "codex-home", "tmp", "arg0-data"), { + recursive: true, + }); + await fs.mkdir(path.join(agentDir, "codex-home", ".tmp-data"), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, "index.ts"), + 'throw new Error("plugin runtime must not activate during backup planning")\n', + "utf8", + ); + await fs.writeFile( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify({ + id: "backup-owner", + configSchema: { type: "object", additionalProperties: false }, + backupResources: [ + { disposition: "regenerable", scope: "state", relativePath: "generated" }, + { disposition: "include", scope: "state", relativePath: "generated/protected" }, + { + disposition: "regenerable", + scope: "state", + relativePath: "state/openclaw.sqlite", + }, + { + disposition: "regenerable", + scope: "agent", + relativePath: "openclaw-agent.sqlite", + }, + { + disposition: "regenerable", + scope: "agent", + relativePath: "codex-home/tmp/arg0", + }, + { + disposition: "include", + scope: "agent", + relativePath: "codex-home/tmp/arg0/protected", + }, + ], + }), + "utf8", + ); + await fs.writeFile(path.join(excludedStateRoot, "unsafe.sqlite"), "not sqlite\n", "utf8"); + await fs.writeFile(path.join(excludedStateRoot, "protected", "keep.txt"), "keep\n", "utf8"); + await fs.writeFile(path.join(excludedAgentRoot, "unsafe.sqlite"), "not sqlite\n", "utf8"); + await fs.writeFile(path.join(excludedAgentRoot, "protected", "keep.txt"), "keep\n", "utf8"); + await fs.writeFile( + path.join(agentDir, "codex-home", "tmp", "arg0-data", "keep.txt"), + "keep\n", + "utf8", + ); + await fs.writeFile( + path.join(agentDir, "codex-home", ".tmp-data", "keep.txt"), + "keep\n", + "utf8", + ); + if (process.platform !== "win32") { + await fs.symlink("/outside-backup", path.join(excludedAgentRoot, "unsafe-link")); + } + await state.writeConfig({ + agents: { entries: { main: { default: true, agentDir } } }, + plugins: { + load: { paths: [pluginRoot] }, + entries: { "backup-owner": { enabled: true } }, + }, + }); + const globalDbPath = resolveCanonicalTestSqlitePath(state, "global"); + const agentDbPath = path.join(agentDir, "openclaw-agent.sqlite"); + await fs.mkdir(path.dirname(globalDbPath), { recursive: true }); + createOwnedSqliteDatabase({ sqlitePath: globalDbPath, role: "global" }); + createOwnedSqliteDatabase({ + sqlitePath: agentDbPath, + role: "agent", + agentId: "main", + }); + + const result = await createBackupArchive({ + output: state.path("plugin-owned.tar.gz"), + includeWorkspace: false, + }); + const entries = await listArchiveEntries(result.archivePath); + + for (const suffix of [ + "/state/state/openclaw.sqlite", + "/state/generated/protected/keep.txt", + "/external-agent/openclaw-agent.sqlite", + "/external-agent/codex-home/tmp/arg0/protected/keep.txt", + "/external-agent/codex-home/tmp/arg0-data/keep.txt", + "/external-agent/codex-home/.tmp-data/keep.txt", + ]) { + expect( + entries.some((entry) => entry.endsWith(suffix)), + suffix, + ).toBe(true); + } + expect(entries.some((entry) => entry.endsWith("/unsafe.sqlite"))).toBe(false); + expect(entries.some((entry) => entry.endsWith("/unsafe-link"))).toBe(false); + expect(result.skipped).toContainEqual( + expect.objectContaining({ sourcePath: excludedAgentRoot, reason: "regenerable" }), + ); + }, + ); + }); + it("falls back when injected nowMs is outside Date range", async () => { await withOpenClawTestState( { diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 138e44292860..da6ba46eb170 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -1,9 +1,9 @@ // Creates backup archives while filtering volatile runtime state. -import type { Stats } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; +import type { BackupAgentRoot } from "../commands/backup-resource-inventory.js"; import { buildBackupArchiveBasename, buildBackupArchivePath, @@ -12,18 +12,11 @@ import { type BackupAsset, resolveBackupPlanFromDisk, } from "../commands/backup-shared.js"; +import type { BackupManifest } from "../commands/backup-verify-manifest.js"; import { isPathWithin } from "../commands/cleanup-utils.js"; import { resolveGatewayLockDir } from "../config/paths.js"; -import { normalizeAgentId } from "../routing/session-key.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; -import { assertOpenClawAgentDatabaseOwner } from "../state/openclaw-agent-db-maintenance.js"; -import { assertOpenClawStateDatabaseOwner } from "../state/openclaw-state-db-maintenance.js"; -import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; -import { - sanitizeOpenClawGlobalStateSnapshot, - sanitizeOpenClawStateLeaseRows, -} from "../state/openclaw-state-snapshot-sanitizer.js"; -import { resolveHomeDir, resolveUserPath, shortenHomePath } from "../utils.js"; +import { resolveHomeDir, resolveUserPath } from "../utils.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { assertArchiveSymbolicLinkTarget } from "./backup-archive-path-policy.js"; import { @@ -37,22 +30,22 @@ import { removePreparedBackupArchive, writeArchiveStreamToFile, } from "./backup-create-stream.js"; +import { + classifyBackupSqliteSource, + createBackupSqliteSnapshotPlan, +} from "./backup-sqlite-snapshot.js"; import { writeTarArchiveWithRetry } from "./backup-tar-retry.js"; -import { isTransientSqliteBackupPath, isVolatileBackupPath } from "./backup-volatile-filter.js"; +import { isVolatileBackupPath } from "./backup-volatile-filter.js"; import { createBackupLinkCache, createBackupVolatileStatCache, } from "./backup-volatile-stat-cache.js"; -import { formatErrorMessage, isErrno } from "./errors.js"; -import { sameFileIdentity } from "./fs-safe-advanced.js"; +import { isErrno } from "./errors.js"; import { writeJson } from "./json-files.js"; -import { createVerifiedSqliteSnapshot } from "./sqlite-snapshot.js"; import { createLegacyAuditBackupSnapshots, hasLegacyAuditBackupSources, isLegacyAuditMigrationBackupPath, - rewriteLegacyAuditBackupCheckpoints, - type LegacyAuditBackupSnapshot, } from "./state-migrations.audit-backup.js"; import { withLegacyAuditMigrationLease } from "./state-migrations.audit-coordination.js"; @@ -74,37 +67,7 @@ export type BackupCreateOptions = { log?: (message: string) => void; }; -type BackupManifestAsset = { - kind: BackupAsset["kind"]; - sourcePath: string; - archivePath: string; -}; - -type BackupManifest = { - schemaVersion: 1; - createdAt: string; - archiveRoot: string; - runtimeVersion: string; - platform: NodeJS.Platform; - nodeVersion: string; - options: { - includeWorkspace: boolean; - onlyConfig?: boolean; - }; - paths: { - stateDir: string; - configPath: string; - oauthDir: string; - workspaceDirs: string[]; - }; - assets: BackupManifestAsset[]; - skipped: Array<{ - kind: string; - sourcePath: string; - reason: string; - coveredBy?: string; - }>; -}; +type BackupManifestAgentRoot = Pick; export type BackupCreateResult = { createdAt: string; @@ -115,6 +78,7 @@ export type BackupCreateResult = { onlyConfig: boolean; verified: boolean; assets: BackupAsset[]; + agentRoots?: readonly BackupManifestAgentRoot[]; skipped: Array<{ kind: string; sourcePath: string; @@ -285,6 +249,7 @@ function buildManifest(params: { configPath: string; oauthDir: string; workspaceDirs: string[]; + agentRoots: readonly BackupAgentRoot[]; }): BackupManifest { return { schemaVersion: 1, @@ -302,6 +267,14 @@ function buildManifest(params: { configPath: params.configPath, oauthDir: params.oauthDir, workspaceDirs: params.workspaceDirs, + ...(params.onlyConfig + ? {} + : { + agentRoots: params.agentRoots.map(({ agentId, sourcePath }) => ({ + agentId, + sourcePath, + })), + }), }, assets: params.assets.map((asset) => ({ kind: asset.kind, @@ -368,435 +341,10 @@ function remapArchiveEntryPath(params: { return buildBackupArchivePath(params.archiveRoot, normalizedEntry); } -function normalizeBackupFilterPath(value: string): string { - return value.replaceAll("\\", "/").replace(/\/+$/u, ""); -} - -const NON_AUTHORITATIVE_STATE_ROOTS = new Set(["dev", "git", "npm", "npm-runtime", "tmp", "tools"]); - -function buildStateBackupFilter( - stateDir: string, - preservedStatePaths: readonly string[] = [], - omittedAgentTemporaryRoots?: Set, -): (filePath: string) => boolean { - const normalizedStateDir = normalizeBackupFilterPath(stateDir); - const statePrefix = `${normalizedStateDir}/`; - const resolvedPreservedPaths = preservedStatePaths.map((entry) => path.resolve(entry)); - - return (filePath: string): boolean => { - const normalizedFilePath = normalizeBackupFilterPath(filePath); - if (!normalizedFilePath.startsWith(statePrefix)) { - return true; - } - - const segments = normalizedFilePath.slice(statePrefix.length).split("/"); - const agentTemporaryIndex = - segments[0] === "agents" && segments[1] && segments[2] === "agent" - ? segments.findIndex( - (segment, index) => index >= 3 && (segment === "tmp" || segment === ".tmp"), - ) - : -1; - if (NON_AUTHORITATIVE_STATE_ROOTS.has(segments[0] ?? "") || agentTemporaryIndex !== -1) { - const resolvedFilePath = path.resolve(filePath); - // Configured user paths remain authoritative inside regenerable roots; - // keep their ancestors traversable without admitting temporary siblings. - const preserved = resolvedPreservedPaths.some( - (preservedPath) => - isPathWithin(resolvedFilePath, preservedPath) || - isPathWithin(preservedPath, resolvedFilePath), - ); - if (!preserved && agentTemporaryIndex !== -1) { - omittedAgentTemporaryRoots?.add( - path.join(stateDir, ...segments.slice(0, agentTemporaryIndex + 1)), - ); - } - return preserved; - } - - return segments[0] !== "extensions" || !segments.includes("node_modules"); - }; -} - -type SqliteBackupAsset = { - sourcePath: string; - archiveSourcePath: string; - skippedSourcePaths: Set; -}; - -type CanonicalSqliteSource = { - archiveSourcePath: string; - identity: Stats; - sourcePath: string; -} & ({ role: "global" } | { role: "agent"; agentId: string }); - -type StateSqliteBackupPlan = { - snapshots: SqliteBackupAsset[]; - discoveredSourcePaths: Set; - omittedAgentTemporaryRoots: Set; -}; - -const SQLITE_BACKUP_SOURCE_SUFFIXES = ["", "-wal", "-shm", "-journal"] as const; - -function isCanonicalAgentSqlitePathOrAncestor(sourcePath: string, stateDir: string): boolean { - const relativePath = path.relative(path.resolve(stateDir), path.resolve(sourcePath)); - const segments = relativePath.split(path.sep); - if (segments[0] !== "agents" || !segments[1]) { - return false; - } - if (segments.length === 2) { - return true; - } - if (segments[2] !== "agent") { - return false; - } - if (segments.length === 3) { - return true; - } - if (segments.length !== 4) { - return false; - } - return SQLITE_BACKUP_SOURCE_SUFFIXES.some( - (suffix) => segments[3] === `openclaw-agent.sqlite${suffix}`, - ); -} - -function resolveCanonicalAgentSqliteDatabaseAgentId( - sourcePath: string, - stateDir: string, -): string | undefined { - const relativePath = path.relative(path.resolve(stateDir), path.resolve(sourcePath)); - const segments = relativePath.split(path.sep); - if ( - segments.length === 4 && - segments[0] === "agents" && - Boolean(segments[1]) && - segments[2] === "agent" && - segments[3] === "openclaw-agent.sqlite" - ) { - return segments[1]; - } - return undefined; -} - -function isCanonicalAgentSqliteDatabasePath(sourcePath: string, stateDir: string): boolean { - return resolveCanonicalAgentSqliteDatabaseAgentId(sourcePath, stateDir) !== undefined; -} - -function isStatePackageContentPath(sourcePath: string, stateDir: string): boolean { - const resolvedStateDir = path.resolve(stateDir); - const resolvedSourcePath = path.resolve(sourcePath); - return ( - isPathWithin(resolvedSourcePath, resolvedStateDir) && - !isCanonicalAgentSqlitePathOrAncestor(resolvedSourcePath, resolvedStateDir) && - path.relative(resolvedStateDir, resolvedSourcePath).split(path.sep).includes("node_modules") - ); -} - -function resolveSqliteBackupDatabasePath(sourcePath: string): string | undefined { - for (const suffix of SQLITE_BACKUP_SOURCE_SUFFIXES.slice(1)) { - if (sourcePath.endsWith(suffix)) { - const databasePath = sourcePath.slice(0, -suffix.length); - return databasePath.endsWith(".sqlite") ? databasePath : undefined; - } - } - return sourcePath.endsWith(".sqlite") ? sourcePath : undefined; -} - -function classifyStateSqliteBackupSourcePath( - sourcePath: string, - stateDir: string, -): "excluded" | "sqlite" | undefined { - const resolvedSourcePath = path.resolve(sourcePath); - if (!isPathWithin(resolvedSourcePath, stateDir)) { - return undefined; - } - if (isStatePackageContentPath(resolvedSourcePath, stateDir)) { - return undefined; - } - if (isTransientSqliteBackupPath(resolvedSourcePath)) { - return "excluded"; - } - const databasePath = resolveSqliteBackupDatabasePath(resolvedSourcePath); - if (!databasePath) { - return undefined; - } - return "sqlite"; -} - function isBackupTarFilterFile(entry: import("node:fs").Stats | import("tar").ReadEntry): boolean { return "isFile" in entry ? entry.isFile() : entry.type === "File"; } -async function listStateSqlitePaths(params: { - stateDir: string; - globalStateSqlitePath: string; - gatewayLockDir: string; - preservedStatePaths?: readonly string[]; -}): Promise<{ - snapshotPaths: string[]; - discoveredSourcePaths: Set; - omittedAgentTemporaryRoots: Set; -}> { - const snapshotPaths = new Set(); - const discoveredSourcePaths = new Set(); - const omittedAgentTemporaryRoots = new Set(); - const stateFilter = buildStateBackupFilter( - params.stateDir, - params.preservedStatePaths, - omittedAgentTemporaryRoots, - ); - const volatilePlan = { stateDirs: [params.stateDir] }; - async function visit(dir: string): Promise { - let entries: import("node:fs").Dirent[]; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - if (!stateFilter(entryPath) || isVolatileBackupPath(entryPath, volatilePlan)) { - continue; - } - // Preserve noncanonical state-tree symlinks instead of dereferencing - // their SQLite-looking targets. Canonical agent DBs mirror the global - // DB contract: snapshot the target so restore receives a regular file. - if (entry.isSymbolicLink()) { - if (isCanonicalAgentSqliteDatabasePath(entryPath, params.stateDir)) { - let targetEntry: import("node:fs").Stats; - try { - targetEntry = await fs.stat(entryPath); - } catch (err) { - throw new Error(`Canonical agent SQLite symlink cannot be snapshotted: ${entryPath}`, { - cause: err, - }); - } - if (!targetEntry.isFile()) { - throw new Error( - `Canonical agent SQLite symlink must resolve to a regular file: ${entryPath}`, - ); - } - const resolvedEntryPath = path.resolve(entryPath); - snapshotPaths.add(resolvedEntryPath); - discoveredSourcePaths.add(resolvedEntryPath); - } - continue; - } - if (entry.isDirectory()) { - if ( - !isPathWithin(entryPath, params.gatewayLockDir) && - !isStatePackageContentPath(entryPath, params.stateDir) - ) { - await visit(entryPath); - } - } else if (entry.isFile() && !isStatePackageContentPath(entryPath, params.stateDir)) { - const resolvedEntryPath = path.resolve(entryPath); - const sqliteSourceKind = classifyStateSqliteBackupSourcePath( - resolvedEntryPath, - params.stateDir, - ); - if (sqliteSourceKind === "sqlite") { - discoveredSourcePaths.add(resolvedEntryPath); - } - if (entry.name.endsWith(".sqlite") && sqliteSourceKind === "sqlite") { - snapshotPaths.add(resolvedEntryPath); - } - } - } - } - await visit(params.stateDir); - - const globalStateSqlitePath = path.resolve(params.globalStateSqlitePath); - let globalStateEntry: import("node:fs").Stats | undefined; - try { - globalStateEntry = await fs.lstat(globalStateSqlitePath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - } - if (globalStateEntry?.isFile()) { - snapshotPaths.add(globalStateSqlitePath); - discoveredSourcePaths.add(globalStateSqlitePath); - } else if (globalStateEntry?.isSymbolicLink()) { - let targetEntry: import("node:fs").Stats; - try { - targetEntry = await fs.stat(globalStateSqlitePath); - } catch (err) { - throw new Error( - `Canonical global SQLite symlink cannot be snapshotted: ${globalStateSqlitePath}`, - { cause: err }, - ); - } - if (!targetEntry.isFile()) { - throw new Error( - `Canonical global SQLite symlink must resolve to a regular file: ${globalStateSqlitePath}`, - ); - } - snapshotPaths.add(globalStateSqlitePath); - discoveredSourcePaths.add(globalStateSqlitePath); - } else if (globalStateEntry) { - throw new Error( - `Canonical global SQLite path must be a regular file or symlink to one: ${globalStateSqlitePath}`, - ); - } - - return { - snapshotPaths: [...snapshotPaths].toSorted((left, right) => left.localeCompare(right)), - discoveredSourcePaths, - omittedAgentTemporaryRoots, - }; -} - -async function createStateSqliteBackupPlan(params: { - stateDir: string; - tempDir: string; - preservedStatePaths?: readonly string[]; - legacyAuditSnapshots: readonly LegacyAuditBackupSnapshot[]; -}): Promise { - // Complete discovery before writing snapshots. chooseBackupTempRoot keeps - // tempDir outside stateDir, and this ordering prevents future overlap from - // making backup discover one of its own staged SQLite files. - const globalStateSqlitePath = path.resolve( - resolveOpenClawStateSqlitePath({ - ...process.env, - OPENCLAW_STATE_DIR: params.stateDir, - }), - ); - const discovery = await listStateSqlitePaths({ - stateDir: params.stateDir, - globalStateSqlitePath, - gatewayLockDir: resolveGatewayLockDir(params.stateDir), - preservedStatePaths: params.preservedStatePaths, - }); - const globalStateIdentity = await fs.stat(globalStateSqlitePath).catch((error: unknown) => { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return undefined; - } - throw error; - }); - const canonicalGlobalSourcePath = globalStateIdentity - ? await fs.realpath(globalStateSqlitePath) - : globalStateSqlitePath; - const canonicalSources: CanonicalSqliteSource[] = []; - if (globalStateIdentity) { - canonicalSources.push({ - role: "global", - archiveSourcePath: globalStateSqlitePath, - identity: globalStateIdentity, - sourcePath: canonicalGlobalSourcePath, - }); - } - canonicalSources.push( - ...(await Promise.all( - discovery.snapshotPaths - .filter((sourcePath) => isCanonicalAgentSqliteDatabasePath(sourcePath, params.stateDir)) - .map(async (sourcePath) => { - const agentId = resolveCanonicalAgentSqliteDatabaseAgentId(sourcePath, params.stateDir); - if (!agentId) { - throw new Error(`Canonical agent SQLite path has no agent owner: ${sourcePath}`); - } - if (normalizeAgentId(agentId) !== agentId) { - throw new Error( - `Canonical agent SQLite path has a noncanonical agent owner ${agentId}: ${sourcePath}`, - ); - } - return { - role: "agent" as const, - agentId, - archiveSourcePath: sourcePath, - identity: await fs.stat(sourcePath), - sourcePath: await fs.realpath(sourcePath), - }; - }), - )), - ); - const snapshots: SqliteBackupAsset[] = []; - for (const archiveSourcePath of discovery.snapshotPaths) { - // A discovered *.sqlite file that SQLite cannot snapshot aborts backup. - // Raw-copying malformed or unreadable databases would restore unsafe state. - const archiveSourceIdentity = await fs.stat(archiveSourcePath); - const exactCanonicalSource = canonicalSources.find( - (source) => path.resolve(source.archiveSourcePath) === path.resolve(archiveSourcePath), - ); - if ( - exactCanonicalSource && - !sameFileIdentity(exactCanonicalSource.identity, archiveSourceIdentity) - ) { - throw new Error(`Canonical SQLite path changed after discovery: ${archiveSourcePath}`); - } - const matchingCanonicalSources = exactCanonicalSource - ? [exactCanonicalSource] - : canonicalSources.filter((source) => - sameFileIdentity(source.identity, archiveSourceIdentity), - ); - if (matchingCanonicalSources.length > 1) { - const owners = matchingCanonicalSources - .map((source) => (source.role === "global" ? "global" : `agent:${source.agentId}`)) - .join(", "); - throw new Error( - `SQLite path aliases multiple canonical database owners (${owners}): ${archiveSourcePath}`, - ); - } - const canonicalSource = matchingCanonicalSources[0]; - // Every alias of a canonical DB must read that database's WAL and receive - // the same role-specific transient-row sanitizer. Exact canonical paths - // keep their own owner even when another canonical path shares the inode. - const sourceDatabasePath = canonicalSource?.sourcePath ?? archiveSourcePath; - const sourcePath = path.join(params.tempDir, `openclaw-state-db-${snapshots.length}.sqlite`); - try { - await createVerifiedSqliteSnapshot({ - sourcePath: sourceDatabasePath, - targetPath: sourcePath, - requireNonEmptySource: Boolean(canonicalSource), - validate: - canonicalSource?.role === "global" - ? (database, pathname) => - assertOpenClawStateDatabaseOwner(database, { - pathname, - }) - : canonicalSource?.role === "agent" - ? (database, pathname) => - assertOpenClawAgentDatabaseOwner(database, { - agentId: canonicalSource.agentId, - pathname, - }) - : undefined, - // Agent coordination is transient, while unrelated plugin databases - // remain owner-defined. Queue and TTL-blob policy is global-only. - transform: - canonicalSource?.role === "global" - ? (database) => { - sanitizeOpenClawGlobalStateSnapshot(database); - rewriteLegacyAuditBackupCheckpoints(database, params.legacyAuditSnapshots); - } - : canonicalSource?.role === "agent" - ? sanitizeOpenClawStateLeaseRows - : undefined, - }); - } catch (err) { - throw new Error( - `SQLite database cannot be compacted safely for backup: ${archiveSourcePath}. ${formatErrorMessage(err)}. The source must pass full integrity checks, online SQLite backup, and offline compaction with its required SQLite capabilities; a direct file copy was refused because it can retain deleted data.`, - { cause: err }, - ); - } - snapshots.push({ - sourcePath, - archiveSourcePath, - skippedSourcePaths: new Set( - [archiveSourcePath, sourceDatabasePath].flatMap((databasePath) => - SQLITE_BACKUP_SOURCE_SUFFIXES.map((suffix) => path.resolve(`${databasePath}${suffix}`)), - ), - ), - }); - } - return { - snapshots, - discoveredSourcePaths: discovery.discoveredSourcePaths, - omittedAgentTemporaryRoots: discovery.omittedAgentTemporaryRoots, - }; -} - export async function createBackupArchive( opts: BackupCreateOptions = {}, ): Promise { @@ -836,9 +384,6 @@ export async function createBackupArchive( const createdAt = new Date(nowMs).toISOString(); const stateAsset = plan.included.find((asset) => asset.kind === "state"); - const pluginSkillsPath = stateAsset - ? path.join(stateAsset.sourcePath, "plugin-skills") - : undefined; const result: BackupCreateResult = { createdAt, archiveRoot, @@ -848,6 +393,14 @@ export async function createBackupArchive( onlyConfig, verified: false, assets: plan.included, + ...(onlyConfig + ? {} + : { + agentRoots: plan.inventory.agentRoots.map(({ agentId, sourcePath }) => ({ + agentId, + sourcePath, + })), + }), skipped: plan.skipped, skippedVolatileCount: 0, }; @@ -869,13 +422,6 @@ export async function createBackupArchive( throw formatBackupOutputFailure(error, outputPath, "publication"); } const tempArchivePath = publication.tempArchivePath; - const preservedStatePaths = [ - plan.configPath, - plan.oauthDir, - ...plan.skipped - .filter((asset) => asset.kind === "workspace" && asset.reason === "covered") - .map((asset) => asset.sourcePath), - ].filter((entry) => stateAsset && isPathWithin(entry, stateAsset.sourcePath)); try { // Capture every legacy file first, including active and claimed sources. // A concurrent Doctor then leaves each row in this snapshot, the later @@ -891,18 +437,13 @@ export async function createBackupArchive( tempDir, }) : []; - const stateSqliteBackup = stateAsset - ? await createStateSqliteBackupPlan({ - stateDir: stateAsset.sourcePath, + const stateSqliteBackup = !onlyConfig + ? await createBackupSqliteSnapshotPlan({ + inventory: plan.inventory, tempDir, - preservedStatePaths, legacyAuditSnapshots, }) - : { - snapshots: [], - discoveredSourcePaths: new Set(), - omittedAgentTemporaryRoots: new Set(), - }; + : { snapshots: [], discoveredSourcePaths: new Set() }; return { legacyAuditSnapshots, stateSqliteBackup }; }; const snapshotPlans = @@ -924,20 +465,6 @@ export async function createBackupArchive( skippedStateSourcePaths.add(skippedSourcePath); } } - const regenerableRoots = [...stateSqliteBackup.omittedAgentTemporaryRoots] - .toSorted((left, right) => left.localeCompare(right)) - .map((sourcePath) => ({ kind: "agent temporary files", sourcePath })); - if (pluginSkillsPath && (await fs.lstat(pluginSkillsPath).catch(() => undefined))) { - regenerableRoots.push({ kind: "plugin skills", sourcePath: pluginSkillsPath }); - } - for (const { kind, sourcePath } of regenerableRoots) { - result.skipped.push({ - kind, - sourcePath, - displayPath: shortenHomePath(sourcePath), - reason: "regenerable", - }); - } const manifest = buildManifest({ createdAt, archiveRoot, @@ -949,13 +476,11 @@ export async function createBackupArchive( configPath: plan.configPath, oauthDir: plan.oauthDir, workspaceDirs: plan.workspaceDirs, + agentRoots: plan.inventory.agentRoots, }); await writeJson(manifestPath, manifest, { trailingNewline: true }); const tar = await loadTarRuntime(); - const stateFilter = stateAsset - ? buildStateBackupFilter(stateAsset.sourcePath, preservedStatePaths) - : undefined; const gatewayLockDir = resolveGatewayLockDir(plan.stateDir); const volatilePlan = { stateDirs: [stateAsset?.sourcePath ?? plan.stateDir] }; let skippedVolatileCount = 0; @@ -973,12 +498,14 @@ export async function createBackupArchive( if (resolvedEntryPath === manifestPath) { return true; } - // This OpenClaw-owned symlink index is rebuilt from plugin metadata. - // Archiving it would preserve host-specific absolute targets. - if (pluginSkillsPath && isPathWithin(resolvedEntryPath, pluginSkillsPath)) { - return false; - } - if (stateFilter && !stateFilter(entryPath)) { + const isDirectory = + "isDirectory" in entryStat ? entryStat.isDirectory() : entryStat.type === "Directory"; + if ( + !onlyConfig && + !(isDirectory + ? plan.inventory.isTraversable(resolvedEntryPath) + : plan.inventory.isIncluded(resolvedEntryPath)) + ) { return false; } if (isPathWithin(resolvedEntryPath, gatewayLockDir)) { @@ -990,9 +517,9 @@ export async function createBackupArchive( ) { return false; } - const sqliteSourceKind = stateAsset - ? classifyStateSqliteBackupSourcePath(resolvedEntryPath, stateAsset.sourcePath) - : undefined; + const sqliteSourceKind = onlyConfig + ? undefined + : classifyBackupSqliteSource(resolvedEntryPath, plan.inventory); if (sqliteSourceKind === "excluded") { return false; } @@ -1121,4 +648,3 @@ export async function createBackupArchive( return result; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/infra/backup-sqlite-snapshot.ts b/src/infra/backup-sqlite-snapshot.ts new file mode 100644 index 000000000000..f8d816840635 --- /dev/null +++ b/src/infra/backup-sqlite-snapshot.ts @@ -0,0 +1,362 @@ +// Snapshots every SQLite database owned by the frozen backup resource inventory. +import type { Dirent, Stats } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { BackupResourceInventory } from "../commands/backup-resource-inventory.js"; +import { isPathWithin } from "../commands/cleanup-utils.js"; +import { resolveGatewayLockDir } from "../config/paths.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { assertOpenClawAgentDatabaseOwner } from "../state/openclaw-agent-db-maintenance.js"; +import { assertOpenClawStateDatabaseOwner } from "../state/openclaw-state-db-maintenance.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { + sanitizeOpenClawGlobalStateSnapshot, + sanitizeOpenClawStateLeaseRows, +} from "../state/openclaw-state-snapshot-sanitizer.js"; +import { isTransientSqliteBackupPath, isVolatileBackupPath } from "./backup-volatile-filter.js"; +import { hasErrnoCode } from "./errno.js"; +import { formatErrorMessage } from "./errors.js"; +import { sameFileIdentity } from "./fs-safe-advanced.js"; +import { resolveSqliteDatabaseFilePaths, SQLITE_SIDECAR_SUFFIXES } from "./sqlite-files.js"; +import { createVerifiedSqliteSnapshot } from "./sqlite-snapshot.js"; +import { + rewriteLegacyAuditBackupCheckpoints, + type LegacyAuditBackupSnapshot, +} from "./state-migrations.audit-backup.js"; + +type SqliteBackupAsset = { + sourcePath: string; + archiveSourcePath: string; + skippedSourcePaths: Set; +}; + +type CanonicalSqliteSource = { + archiveSourcePath: string; + identity: Stats; + sourcePath: string; +} & ({ role: "global" } | { role: "agent"; agentId: string }); + +function resolveBackupAgentDatabaseOwner( + sourcePath: string, + inventory: BackupResourceInventory, +): string | undefined { + const resolvedSourcePath = path.resolve(sourcePath); + if (path.basename(resolvedSourcePath) !== "openclaw-agent.sqlite") { + return undefined; + } + + const stateSegments = path.relative(inventory.stateDir, resolvedSourcePath).split(path.sep); + const defaultLayoutAgentId = + stateSegments.length === 4 && stateSegments[0] === "agents" && stateSegments[2] === "agent" + ? stateSegments[1] + : undefined; + // Case-insensitive filesystems can resolve a configured `main` root to an + // on-disk `Main` directory; physical owner spelling still must fail closed. + if (defaultLayoutAgentId && normalizeAgentId(defaultLayoutAgentId) !== defaultLayoutAgentId) { + throw new Error( + `Canonical agent SQLite path has a noncanonical agent owner ${defaultLayoutAgentId}: ${resolvedSourcePath}`, + ); + } + + const declaredOwners = inventory.agentRoots.filter( + ({ databasePath }) => path.resolve(databasePath) === resolvedSourcePath, + ); + if (declaredOwners.length > 1) { + const distinctAgentIds = new Set(declaredOwners.map(({ agentId }) => agentId)); + if (distinctAgentIds.size > 1) { + throw new Error( + `Canonical agent SQLite path has multiple configured owners (${[...distinctAgentIds].join(", ")}): ${resolvedSourcePath}`, + ); + } + } + const configuredAgentId = declaredOwners[0]?.agentId; + if (configuredAgentId) { + return configuredAgentId; + } + + // Older state trees can contain agents absent from the current config. Their + // shipped canonical layout still identifies the owner without a DB registry. + return defaultLayoutAgentId; +} + +function resolveSqliteBackupDatabasePath(sourcePath: string): string | undefined { + for (const suffix of SQLITE_SIDECAR_SUFFIXES) { + if (sourcePath.endsWith(suffix)) { + const databasePath = sourcePath.slice(0, -suffix.length); + return databasePath.endsWith(".sqlite") ? databasePath : undefined; + } + } + return sourcePath.endsWith(".sqlite") ? sourcePath : undefined; +} + +export function classifyBackupSqliteSource( + sourcePath: string, + inventory: BackupResourceInventory, +): "excluded" | "sqlite" | undefined { + const resolvedSourcePath = path.resolve(sourcePath); + const transient = isTransientSqliteBackupPath(resolvedSourcePath); + const databasePath = resolveSqliteBackupDatabasePath(resolvedSourcePath); + if (!transient && !databasePath) { + return undefined; + } + const withinOwnedRoot = + isPathWithin(resolvedSourcePath, inventory.stateDir) || + inventory.agentRoots.some(({ sourcePath: agentRoot }) => + isPathWithin(resolvedSourcePath, agentRoot), + ); + if (!withinOwnedRoot || inventory.isPackageContent(resolvedSourcePath)) { + return undefined; + } + if (transient) { + return "excluded"; + } + return inventory.isIncluded(resolvedSourcePath) ? "sqlite" : "excluded"; +} + +async function discoverBackupSqliteSources(params: { + inventory: BackupResourceInventory; + globalStateSqlitePath: string; +}): Promise<{ snapshotPaths: string[]; discoveredSourcePaths: Set }> { + const snapshotPaths = new Set(); + const discoveredSourcePaths = new Set(); + const visitedDirectories = new Set(); + const gatewayLockDir = resolveGatewayLockDir(params.inventory.stateDir); + const volatilePlan = { stateDirs: [params.inventory.stateDir] }; + + async function visit(directoryPath: string): Promise { + const resolvedDirectoryPath = path.resolve(directoryPath); + if (visitedDirectories.has(resolvedDirectoryPath)) { + return; + } + visitedDirectories.add(resolvedDirectoryPath); + + let entries: Dirent[]; + try { + entries = await fs.readdir(resolvedDirectoryPath, { withFileTypes: true }); + } catch (error) { + if (hasErrnoCode(error, "ENOENT")) { + return; + } + throw error; + } + + for (const entry of entries) { + const entryPath = path.join(resolvedDirectoryPath, entry.name); + if ( + isPathWithin(entryPath, gatewayLockDir) || + isVolatileBackupPath(entryPath, volatilePlan) + ) { + continue; + } + if (entry.isDirectory()) { + if ( + params.inventory.isTraversable(entryPath) && + !params.inventory.isPackageContent(entryPath) + ) { + await visit(entryPath); + } + continue; + } + // Exclusions win before symlink/stat handling; protected declarations + // are already resolved by the inventory's include-over-exclude policy. + if (!params.inventory.isIncluded(entryPath)) { + continue; + } + if (entry.isSymbolicLink()) { + if (resolveBackupAgentDatabaseOwner(entryPath, params.inventory)) { + let targetEntry: Stats; + try { + targetEntry = await fs.stat(entryPath); + } catch (error) { + throw new Error(`Canonical agent SQLite symlink cannot be snapshotted: ${entryPath}`, { + cause: error, + }); + } + if (!targetEntry.isFile()) { + throw new Error( + `Canonical agent SQLite symlink must resolve to a regular file: ${entryPath}`, + ); + } + snapshotPaths.add(entryPath); + discoveredSourcePaths.add(entryPath); + } + continue; + } + if (!entry.isFile() || classifyBackupSqliteSource(entryPath, params.inventory) !== "sqlite") { + continue; + } + discoveredSourcePaths.add(entryPath); + if (entry.name.endsWith(".sqlite")) { + snapshotPaths.add(entryPath); + } + } + } + + await visit(params.inventory.stateDir); + for (const { sourcePath } of params.inventory.agentRoots) { + await visit(sourcePath); + } + + const globalStateSqlitePath = path.resolve(params.globalStateSqlitePath); + let globalStateEntry: Stats | undefined; + try { + globalStateEntry = await fs.lstat(globalStateSqlitePath); + } catch (error) { + if (!hasErrnoCode(error, "ENOENT")) { + throw error; + } + } + if (globalStateEntry?.isFile()) { + snapshotPaths.add(globalStateSqlitePath); + discoveredSourcePaths.add(globalStateSqlitePath); + } else if (globalStateEntry?.isSymbolicLink()) { + let targetEntry: Stats; + try { + targetEntry = await fs.stat(globalStateSqlitePath); + } catch (error) { + throw new Error( + `Canonical global SQLite symlink cannot be snapshotted: ${globalStateSqlitePath}`, + { cause: error }, + ); + } + if (!targetEntry.isFile()) { + throw new Error( + `Canonical global SQLite symlink must resolve to a regular file: ${globalStateSqlitePath}`, + ); + } + snapshotPaths.add(globalStateSqlitePath); + discoveredSourcePaths.add(globalStateSqlitePath); + } else if (globalStateEntry) { + throw new Error( + `Canonical global SQLite path must be a regular file or symlink to one: ${globalStateSqlitePath}`, + ); + } + + return { + snapshotPaths: [...snapshotPaths].toSorted((left, right) => left.localeCompare(right)), + discoveredSourcePaths, + }; +} + +export async function createBackupSqliteSnapshotPlan(params: { + inventory: BackupResourceInventory; + tempDir: string; + legacyAuditSnapshots: readonly LegacyAuditBackupSnapshot[]; +}): Promise<{ snapshots: SqliteBackupAsset[]; discoveredSourcePaths: Set }> { + const globalStateSqlitePath = path.resolve( + resolveOpenClawStateSqlitePath({ + ...process.env, + OPENCLAW_STATE_DIR: params.inventory.stateDir, + }), + ); + // Discovery finishes before snapshot creation so staged files cannot become + // additional backup sources, even when authoritative roots overlap. + const discovery = await discoverBackupSqliteSources({ + inventory: params.inventory, + globalStateSqlitePath, + }); + const globalStateIdentity = await fs.stat(globalStateSqlitePath).catch((error: unknown) => { + if (hasErrnoCode(error, "ENOENT")) { + return undefined; + } + throw error; + }); + const canonicalSources: CanonicalSqliteSource[] = []; + if (globalStateIdentity) { + canonicalSources.push({ + role: "global", + archiveSourcePath: globalStateSqlitePath, + identity: globalStateIdentity, + sourcePath: await fs.realpath(globalStateSqlitePath), + }); + } + for (const archiveSourcePath of discovery.snapshotPaths) { + const agentId = resolveBackupAgentDatabaseOwner(archiveSourcePath, params.inventory); + if (!agentId) { + continue; + } + if (normalizeAgentId(agentId) !== agentId) { + throw new Error( + `Canonical agent SQLite path has a noncanonical agent owner ${agentId}: ${archiveSourcePath}`, + ); + } + canonicalSources.push({ + role: "agent", + agentId, + archiveSourcePath, + identity: await fs.stat(archiveSourcePath), + sourcePath: await fs.realpath(archiveSourcePath), + }); + } + + const snapshots: SqliteBackupAsset[] = []; + for (const archiveSourcePath of discovery.snapshotPaths) { + const archiveSourceIdentity = await fs.stat(archiveSourcePath); + const exactCanonicalSource = canonicalSources.find( + (source) => path.resolve(source.archiveSourcePath) === path.resolve(archiveSourcePath), + ); + if ( + exactCanonicalSource && + !sameFileIdentity(exactCanonicalSource.identity, archiveSourceIdentity) + ) { + throw new Error(`Canonical SQLite path changed after discovery: ${archiveSourcePath}`); + } + const matchingCanonicalSources = exactCanonicalSource + ? [exactCanonicalSource] + : canonicalSources.filter((source) => + sameFileIdentity(source.identity, archiveSourceIdentity), + ); + if (matchingCanonicalSources.length > 1) { + const owners = matchingCanonicalSources + .map((source) => (source.role === "global" ? "global" : `agent:${source.agentId}`)) + .join(", "); + throw new Error( + `SQLite path aliases multiple canonical database owners (${owners}): ${archiveSourcePath}`, + ); + } + const canonicalSource = matchingCanonicalSources[0]; + const sourceDatabasePath = canonicalSource?.sourcePath ?? archiveSourcePath; + const sourcePath = path.join(params.tempDir, `openclaw-state-db-${snapshots.length}.sqlite`); + try { + await createVerifiedSqliteSnapshot({ + sourcePath: sourceDatabasePath, + targetPath: sourcePath, + requireNonEmptySource: Boolean(canonicalSource), + validate: + canonicalSource?.role === "global" + ? (database, pathname) => assertOpenClawStateDatabaseOwner(database, { pathname }) + : canonicalSource?.role === "agent" + ? (database, pathname) => + assertOpenClawAgentDatabaseOwner(database, { + agentId: canonicalSource.agentId, + pathname, + }) + : undefined, + transform: + canonicalSource?.role === "global" + ? (database) => { + sanitizeOpenClawGlobalStateSnapshot(database); + rewriteLegacyAuditBackupCheckpoints(database, params.legacyAuditSnapshots); + } + : canonicalSource?.role === "agent" + ? sanitizeOpenClawStateLeaseRows + : undefined, + }); + } catch (error) { + throw new Error( + `SQLite database cannot be compacted safely for backup: ${archiveSourcePath}. ${formatErrorMessage(error)}. The source must pass full integrity checks, online SQLite backup, and offline compaction with its required SQLite capabilities; a direct file copy was refused because it can retain deleted data.`, + { cause: error }, + ); + } + snapshots.push({ + sourcePath, + archiveSourcePath, + skippedSourcePaths: new Set( + [archiveSourcePath, sourceDatabasePath].flatMap((databasePath) => + resolveSqliteDatabaseFilePaths(databasePath).map((pathname) => path.resolve(pathname)), + ), + ), + }); + } + return { snapshots, discoveredSourcePaths: discovery.discoveredSourcePaths }; +} diff --git a/src/infra/sqlite-files.ts b/src/infra/sqlite-files.ts index 53229f8ac792..a3a504a2c6c9 100644 --- a/src/infra/sqlite-files.ts +++ b/src/infra/sqlite-files.ts @@ -5,6 +5,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; /** SQLite main database plus every journal-mode sidecar that can contain database pages. */ const SQLITE_DATABASE_FILE_SUFFIXES = ["", "-wal", "-shm", "-journal"] as const; +export const SQLITE_SIDECAR_SUFFIXES = SQLITE_DATABASE_FILE_SUFFIXES.slice(1); // SQLite WAL format: https://sqlite.org/fileformat2.html#walformat defines a 32-byte header. const SQLITE_WAL_HEADER_BYTES = 32; const SQLITE_SIDECAR_HASH_BUFFER_BYTES = 1024 * 1024; diff --git a/src/plugins/manifest-backup-resources.test.ts b/src/plugins/manifest-backup-resources.test.ts new file mode 100644 index 000000000000..5d5295c2b0f2 --- /dev/null +++ b/src/plugins/manifest-backup-resources.test.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveActivatedPluginBackupInventory } from "./manifest-backup-resources.js"; +import { loadPluginManifest } from "./manifest.js"; +import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; + +const roots: string[] = []; + +function createPluginFixture(params: { + id?: string; + backupResources: unknown; + root?: string; + workspace?: boolean; +}) { + const root = + params.root ?? fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "backup-owner-"))); + if (!params.root) { + roots.push(root); + } + const id = params.id ?? "backup-owner"; + const workspaceDir = path.join(root, "workspace"); + const pluginRoot = params.workspace + ? path.join(workspaceDir, ".openclaw", "extensions", id) + : path.join(root, id); + const stateDir = path.join(root, "state"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify({ + id, + configSchema: { type: "object", additionalProperties: false }, + backupResources: params.backupResources, + }), + ); + fs.writeFileSync( + path.join(pluginRoot, "index.ts"), + 'throw new Error("plugin runtime activated")', + ); + return { + id, + pluginRoot, + stateDir, + workspaceDir, + env: { + HOME: root, + OPENCLAW_HOME: root, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "no-bundled-plugins"), + }, + }; +} + +afterEach(() => { + clearPluginMetadataLifecycleCaches(); + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("plugin manifest backup resources", () => { + it("retains closed resource declarations and removes deterministic duplicates", () => { + const include = { disposition: "include", scope: "state", relativePath: "owner/durable" }; + const regenerable = { + disposition: "regenerable", + scope: "agent", + relativePath: "owner/cache", + }; + const fixture = createPluginFixture({ + backupResources: [include, regenerable, include], + }); + + const result = loadPluginManifest(fixture.pluginRoot); + + expect(result).toMatchObject({ + ok: true, + manifest: { backupResources: [regenerable, include] }, + }); + }); + + it.each([ + ["an absolute POSIX path", "/outside"], + ["a Windows absolute path", "C:\\outside"], + ["a Windows drive-relative path", "C:outside"], + ["a Windows UNC path", "\\\\server\\share"], + ["a backslash separator", "owner\\cache"], + ["a NUL byte", "owner/\0cache"], + ["an empty path", ""], + ["an empty segment", "owner//cache"], + ["a trailing empty segment", "owner/cache/"], + ["a current-directory segment", "owner/./cache"], + ["a parent-directory segment", "owner/../outside"], + ["a URI-like value", "https://example.com/cache"], + ])("rejects %s", (_label, relativePath) => { + const fixture = createPluginFixture({ + backupResources: [{ disposition: "include", scope: "state", relativePath }], + }); + + expect(loadPluginManifest(fixture.pluginRoot)).toMatchObject({ + ok: false, + error: expect.stringContaining("strict relative POSIX path"), + }); + }); + + it.each([ + ["non-array declarations", {}], + ["unknown disposition", [{ disposition: "exclude", scope: "state", relativePath: "owner" }]], + ["unknown scope", [{ disposition: "include", scope: "workspace", relativePath: "owner" }]], + [ + "owner-controlled extra keys", + [{ disposition: "include", scope: "state", relativePath: "owner", pluginId: "spoofed" }], + ], + ["missing keys", [{ disposition: "include", relativePath: "owner" }]], + ])("rejects %s", (_label, backupResources) => { + const fixture = createPluginFixture({ backupResources }); + + expect(loadPluginManifest(fixture.pluginRoot)).toMatchObject({ + ok: false, + error: expect.stringContaining("backupResources"), + diagnosticCode: "backup-resource-declaration-invalid", + }); + }); + + it("resolves only activated loadable owners without activating plugin runtime", () => { + const included = { + disposition: "include", + scope: "state", + relativePath: "plugins/backup-owner/durable", + } as const; + const regenerable = { + disposition: "regenerable", + scope: "agent", + relativePath: "backup-owner/cache", + } as const; + const enabled = createPluginFixture({ backupResources: [included, regenerable, included] }); + const disabled = createPluginFixture({ + root: path.dirname(enabled.pluginRoot), + id: "disabled-owner", + backupResources: [{ ...regenerable, relativePath: "disabled-owner/cache" }], + }); + const config: OpenClawConfig = { + plugins: { + load: { paths: [enabled.pluginRoot, disabled.pluginRoot] }, + entries: { + [enabled.id]: { enabled: true }, + [disabled.id]: { enabled: false }, + }, + }, + }; + + expect( + resolveActivatedPluginBackupInventory({ + config, + env: enabled.env, + stateDir: enabled.stateDir, + workspaceDirs: [path.join(path.dirname(enabled.pluginRoot), "workspace")], + }), + ).toEqual({ + pluginRoots: [], + resources: [ + { pluginId: enabled.id, ...regenerable }, + { pluginId: enabled.id, ...included }, + ], + }); + }); + + it("fails closed on invalid declarations from an activated manifest owner", () => { + const fixture = createPluginFixture({ + backupResources: [{ disposition: "include", scope: "state", relativePath: "../outside" }], + }); + const config: OpenClawConfig = { + plugins: { + load: { paths: [fixture.pluginRoot] }, + entries: { [fixture.id]: { enabled: true } }, + }, + }; + + expect(() => + resolveActivatedPluginBackupInventory({ + config, + env: fixture.env, + stateDir: fixture.stateDir, + }), + ).toThrow("invalid plugin manifest backupResources"); + }); + + it.each([ + { label: "ignores", activated: false }, + { label: "fails closed on", activated: true }, + ])( + "$label invalid declarations from a workspace owner when activated=$activated", + ({ activated }) => { + const fixture = createPluginFixture({ + workspace: true, + backupResources: [{ disposition: "include", scope: "state", relativePath: "../outside" }], + }); + const config: OpenClawConfig = activated + ? { plugins: { entries: { [fixture.id]: { enabled: true } } } } + : {}; + const resolveInventory = () => + resolveActivatedPluginBackupInventory({ + config, + env: fixture.env, + stateDir: fixture.stateDir, + workspaceDirs: [fixture.workspaceDir], + }); + + if (activated) { + expect(resolveInventory).toThrow("invalid plugin manifest backupResources"); + } else { + expect(resolveInventory()).toEqual({ pluginRoots: [fixture.pluginRoot], resources: [] }); + } + }, + ); + + it("does not apply declarations when the plugin system is disabled", () => { + const fixture = createPluginFixture({ + backupResources: [{ disposition: "include", scope: "state", relativePath: "owner" }], + }); + + expect( + resolveActivatedPluginBackupInventory({ + config: { + plugins: { enabled: false, load: { paths: [fixture.pluginRoot] } }, + }, + env: fixture.env, + stateDir: fixture.stateDir, + }), + ).toEqual({ pluginRoots: [], resources: [] }); + }); +}); diff --git a/src/plugins/manifest-backup-resources.ts b/src/plugins/manifest-backup-resources.ts new file mode 100644 index 000000000000..267c286bf6ad --- /dev/null +++ b/src/plugins/manifest-backup-resources.ts @@ -0,0 +1,136 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { hasNodeErrorCode, isPathInside } from "../infra/path-guards.js"; +import { normalizePluginsConfig } from "./config-state.js"; +import { resolveDefaultPluginExtensionsDir } from "./install-paths.js"; +import { isManifestPluginAvailableForControlPlane } from "./manifest-contract-eligibility.js"; +import { isActivatedManifestOwner } from "./manifest-owner-policy.js"; +import type { PluginManifestBackupResource } from "./manifest-types.js"; +import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; + +export type ResolvedPluginBackupResource = PluginManifestBackupResource & { + pluginId: string; +}; + +export type ActivatedPluginBackupInventory = { + pluginRoots: string[]; + resources: ResolvedPluginBackupResource[]; +}; + +function listPluginInstallRoots(env: NodeJS.ProcessEnv | undefined): string[] { + const extensionsDir = resolveDefaultPluginExtensionsDir(env); + try { + return fs + .readdirSync(extensionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(extensionsDir, entry.name)); + } catch (error) { + if (hasNodeErrorCode(error, "ENOENT")) { + return []; + } + throw error; + } +} + +/** Resolves effective plugin-owned backup policy without importing or activating plugin runtime. */ +export function resolveActivatedPluginBackupInventory(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDirs?: readonly string[]; +}): ActivatedPluginBackupInventory { + const normalizedConfig = normalizePluginsConfig(params.config.plugins); + const workspaceScopes = params.workspaceDirs?.length + ? [...new Set(params.workspaceDirs)] + : [undefined]; + const backupRoots = [params.stateDir, ...(params.workspaceDirs ?? [])] + .filter((root): root is string => Boolean(root)) + .map((root) => path.resolve(root)); + const pluginRoots = new Set(); + const addPluginRoot = (pluginRoot: string): void => { + const resolvedRoot = path.resolve(pluginRoot); + if ( + backupRoots.some( + (backupRoot) => resolvedRoot === backupRoot || isPathInside(backupRoot, resolvedRoot), + ) + ) { + pluginRoots.add(resolvedRoot); + } + }; + for (const pluginRoot of listPluginInstallRoots(params.env)) { + addPluginRoot(pluginRoot); + } + const resources = new Map(); + for (const workspaceDir of workspaceScopes) { + const snapshot = resolvePluginMetadataSnapshot({ + config: params.config, + env: params.env, + ...(params.stateDir ? { stateDir: params.stateDir } : {}), + ...(workspaceDir ? { workspaceDir } : {}), + }); + for (const candidate of snapshot.discovery?.candidates ?? []) { + addPluginRoot(candidate.rootDir); + } + const invalidDeclaration = normalizedConfig.enabled + ? snapshot.diagnostics.find((diagnostic) => { + if (diagnostic.code !== "backup-resource-declaration-invalid") { + return false; + } + if (!diagnostic.pluginId) { + return true; + } + const indexedOwner = snapshot.index.plugins.find( + (owner) => owner.pluginId === diagnostic.pluginId, + ); + const discoveredOwner = snapshot.discovery?.candidates.find( + (owner) => (owner.diagnosticIdHint ?? owner.idHint) === diagnostic.pluginId, + ); + const owner = indexedOwner ?? discoveredOwner; + if (!owner) { + return true; + } + const plugin = { + id: diagnostic.pluginId, + origin: owner.origin, + enabledByDefault: indexedOwner?.enabledByDefault, + enabledByDefaultOnPlatforms: indexedOwner?.enabledByDefaultOnPlatforms?.slice(), + }; + return ( + isActivatedManifestOwner({ plugin, normalizedConfig, rootConfig: params.config }) && + (!indexedOwner || + isManifestPluginAvailableForControlPlane({ snapshot, plugin, config: params.config })) + ); + }) + : undefined; + if (invalidDeclaration) { + throw new Error(invalidDeclaration.message); + } + for (const plugin of snapshot.plugins) { + if ( + !normalizedConfig.enabled || + !plugin.backupResources?.length || + !isActivatedManifestOwner({ plugin, normalizedConfig, rootConfig: params.config }) || + !isManifestPluginAvailableForControlPlane({ + snapshot, + plugin, + config: params.config, + }) + ) { + continue; + } + for (const resource of plugin.backupResources) { + const key = `${plugin.id}\0${resource.scope}\0${resource.relativePath}\0${resource.disposition}`; + if (!resources.has(key)) { + resources.set(key, { pluginId: plugin.id, ...resource }); + } + } + } + } + return { + pluginRoots: [...pluginRoots].toSorted(), + resources: [...resources.entries()] + .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, resource]) => resource), + }; +} diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index ea5241cabefc..6eb1bf8f42bc 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -34,6 +34,7 @@ import type { PluginConfigUiHint, PluginDiagnostic, PluginFormat, + PluginManifestBackupResource, PluginManifestDoctorContract, } from "./manifest-types.js"; import { @@ -217,6 +218,7 @@ const PLUGIN_ORIGIN_RANK: Readonly> = { export type PluginManifestRecord = { id: string; + backupResources?: PluginManifestBackupResource[]; name?: string; description?: string; catalog?: PluginManifestCatalog; @@ -562,6 +564,7 @@ function buildRecord(params: { ); return { id: pluginId, + backupResources: params.manifest.backupResources, doctorContract: params.manifest.doctorContract, sessionRouteStateOwners: params.manifest.sessionRouteStateOwners, name: normalizeOptionalString(params.manifest.name) ?? params.candidate.packageName, @@ -1113,6 +1116,9 @@ export function loadPluginManifestRegistryCore( pluginId: candidate.diagnosticIdHint ?? candidate.idHint, message: manifestRes.error, source: manifestRes.manifestPath, + ...("diagnosticCode" in manifestRes && manifestRes.diagnosticCode + ? { code: manifestRes.diagnosticCode } + : {}), }); continue; } diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 724ed9369cb2..ac26bb23329e 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -28,6 +28,7 @@ export type PluginBundleFormat = "agent" | "codex" | "claude" | "cursor"; * on these instead of matching freeform diagnostic message text. */ export type PluginDiagnosticCode = + | "backup-resource-declaration-invalid" | "channel-setup-failure" | "dashboard-declaration-invalid" | "plugin-verification" @@ -335,9 +336,18 @@ export type PluginManifestCatalog = { order?: number; }; +/** Declarative backup ownership rooted at host-managed state or each configured agent. */ +export type PluginManifestBackupResource = { + disposition: "include" | "regenerable"; + scope: "state" | "agent"; + relativePath: string; +}; + export type PluginManifest = { id: string; configSchema: JsonSchemaObject; + /** Static backup inclusion/exclusion declarations; resolved without loading plugin runtime. */ + backupResources?: PluginManifestBackupResource[]; /** Plugin ids that must also be installed for this plugin to have effect. */ requiresPlugins?: string[]; enabledByDefault?: boolean; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 7c69d83cc477..32cf2538c8a2 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -12,7 +12,12 @@ import * as capabilityNormalizers from "./manifest-capability-normalizers.js"; import { normalizeManifestCommandAliases } from "./manifest-command-aliases.js"; import * as modelProviderNormalizers from "./manifest-model-provider-normalizers.js"; import * as setupNormalizers from "./manifest-setup-normalizers.js"; -import type { PluginManifest, PluginManifestDoctorContract } from "./manifest-types.js"; +import type { + PluginDiagnosticCode, + PluginManifest, + PluginManifestBackupResource, + PluginManifestDoctorContract, +} from "./manifest-types.js"; import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js"; import type { PluginKind } from "./plugin-kind.types.js"; import { normalizePluginPolicyId } from "./plugin-policy-id.js"; @@ -38,7 +43,12 @@ export function isCoreReservedPluginId(id: string): boolean { type PluginManifestLoadResult = | { ok: true; manifest: PluginManifest; manifestPath: string } - | { ok: false; error: string; manifestPath: string }; + | { + ok: false; + error: string; + manifestPath: string; + diagnosticCode?: PluginDiagnosticCode; + }; type PluginManifestLoadCacheEntry = { result: PluginManifestLoadResult; @@ -125,6 +135,62 @@ function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined { return kinds.length === 0 ? undefined : kinds.length === 1 ? kinds[0] : kinds; } +function parseManifestBackupResources( + raw: unknown, +): { ok: true; resources?: PluginManifestBackupResource[] } | { ok: false; error: string } { + if (raw === undefined) { + return { ok: true }; + } + if (!Array.isArray(raw)) { + return { ok: false, error: "backupResources must be an array" }; + } + const resources = new Map(); + for (const [index, entry] of raw.entries()) { + if ( + !isRecord(entry) || + Object.keys(entry).length !== 3 || + !("disposition" in entry) || + !("scope" in entry) || + !("relativePath" in entry) + ) { + return { + ok: false, + error: `backupResources[${index}] must contain only disposition, scope, and relativePath`, + }; + } + const { disposition, scope, relativePath } = entry; + if (disposition !== "include" && disposition !== "regenerable") { + return { ok: false, error: `backupResources[${index}].disposition is invalid` }; + } + if (scope !== "state" && scope !== "agent") { + return { ok: false, error: `backupResources[${index}].scope is invalid` }; + } + if ( + typeof relativePath !== "string" || + !relativePath || + relativePath.includes("\\") || + relativePath.includes("\0") || + path.posix.isAbsolute(relativePath) || + path.win32.isAbsolute(relativePath) || + /^[A-Za-z][A-Za-z\d+.-]*:/.test(relativePath) || + relativePath.split("/").some((segment) => !segment || segment === "." || segment === "..") + ) { + return { + ok: false, + error: `backupResources[${index}].relativePath must be a strict relative POSIX path`, + }; + } + const resource: PluginManifestBackupResource = { disposition, scope, relativePath }; + resources.set(`${scope}\0${relativePath}\0${disposition}`, resource); + } + return { + ok: true, + resources: [...resources.entries()] + .toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, resource]) => resource), + }; +} + export function loadPluginManifest( rootDir: string, rejectHardlinks = true, @@ -199,6 +265,15 @@ export function loadPluginManifest( if (!configSchema) { return cacheResult({ ok: false, error: "plugin manifest requires configSchema", manifestPath }); } + const backupResources = parseManifestBackupResources(raw.backupResources); + if (!backupResources.ok) { + return cacheResult({ + ok: false, + error: `invalid plugin manifest backupResources: ${backupResources.error}`, + manifestPath, + diagnosticCode: "backup-resource-declaration-invalid", + }); + } const requiresPlugins = normalizeTrimmedStringList(raw.requiresPlugins); const enabledByDefaultOnPlatforms = setupNormalizers.normalizeManifestDefaultPlatforms( @@ -226,6 +301,9 @@ export function loadPluginManifest( const manifestBeforeDashboard = { id, configSchema, + ...(backupResources.resources !== undefined + ? { backupResources: backupResources.resources } + : {}), ...(requiresPlugins.length > 0 ? { requiresPlugins } : {}), ...(raw.enabledByDefault === true ? { enabledByDefault: true } : {}), ...(enabledByDefaultOnPlatforms.length > 0 ? { enabledByDefaultOnPlatforms } : {}), diff --git a/src/snapshot/git-backup.test.ts b/src/snapshot/git-backup.test.ts index c646442d9abd..c245f5b2c7c0 100644 --- a/src/snapshot/git-backup.test.ts +++ b/src/snapshot/git-backup.test.ts @@ -279,6 +279,50 @@ describe("Git-backed SQLite snapshots", () => { expect(await requireGit(repositoryPath, ["rev-list", "--count", "HEAD"])).toBe("1"); }); + it("backs up a configured external agent database for explicit and all scopes", async () => { + const root = await fs.realpath(await tempRoot()); + const { stateDir } = createStateDatabaseFixture(root); + const agentDir = path.join(root, "external-agent"); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.mkdir(agentDir, { recursive: true }); + const { closeOpenClawAgentDatabaseByPath, openOpenClawAgentDatabase } = + await import("../state/openclaw-agent-db.js"); + const agentDatabase = openOpenClawAgentDatabase({ + agentId: "main", + env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, + path: path.join(agentDir, "openclaw-agent.sqlite"), + }); + closeOpenClawAgentDatabaseByPath(agentDatabase.path); + await fs.writeFile(configPath, JSON.stringify({ agents: { entries: { main: { agentDir } } } })); + + await withEnvAsync( + { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath }, + async () => { + for (const { scope, selection } of [ + { scope: "explicit", selection: { agents: ["main"] } }, + { scope: "all", selection: { all: true } }, + ]) { + const repositoryPath = path.join(root, `${scope}-repository`); + const result = await backupGitCreateCommand(createTestRuntime(), { + repository: repositoryPath, + ...selection, + }); + const manifest = JSON.parse( + await fs.readFile(path.join(repositoryPath, "agents", "main", "manifest.json"), "utf8"), + ) as { identity: { role: string; agentId: string } }; + + expect(result.commit).toMatch(/^[a-f0-9]{40}$/u); + expect(manifest.identity).toEqual({ role: "agent", agentId: "main" }); + if (scope === "all") { + await expect( + fs.stat(path.join(repositoryPath, "global", "manifest.json")), + ).resolves.toBeDefined(); + } + } + }, + ); + }); + it("stages only backup-owned paths in an adopted repository", async () => { const root = await tempRoot(); const { stateDir, database } = createStateDatabaseFixture(root);