From 1ec4e4582eac80b816d9644c174ab4a2c163f372 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Wed, 12 Aug 2026 11:37:04 -0700 Subject: [PATCH] fix(claws): freeze installed tool profile authority (#121327) * fix(claws): freeze installed tool profile authority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * chore(claws): normalize consent helper file modes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): preserve consented tool authority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * chore(claws): normalize source file modes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * chore(claws): allowlist runtime provenance probe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): close consent review gaps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * docs(claws): require concrete frozen tool grants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): reject dynamic MCP selectors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): type profile resolution at parse boundary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): preserve bounded update authority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): prepare consent provenance at config load Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): isolate consent provenance failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): keep runtime grants inside consent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): own consent cache in state lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): guide legacy full profile repair Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * chore(claws): keep consent cache internals private Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): satisfy strict consent cache types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): fail closed when state cache closes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): integrate consent cache with state owner Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): fail closed before consent state opens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): resume legacy v1 profile installs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * test(claws): isolate legacy resume regression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): follow tool policy normalizer rename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): bind runtime consent to agent config Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * chore(claws): normalize digest helper mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): drop stale digest helper import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * test(runtime): keep snapshot mocks complete Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): retain bounded legacy profile plans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): verify ownership before runtime consent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * test(claws): remove stale runtime import * fix(claws): drop stale add import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * fix(claws): resume failed v1 promotion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * test(claws): codify cold-state authority fence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 * chore(plugin-sdk): refresh API baseline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 --------- Co-authored-by: Gio Della-Libera <235387111+giodl73-repo@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: db3a73db-01ff-428e-a451-b7b710cd5085 --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-inbound.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- docs/cli/claws.md | 20 +- .../agent-command.live-model-switch.test.ts | 1 + src/agents/agent-tools.policy.ts | 17 +- .../conversation-tool-policy-pipeline.ts | 4 + src/agents/tool-policy-pipeline.test.ts | 28 ++ src/agents/tool-policy-pipeline.ts | 10 +- src/claws/add-plan-helpers.ts | 44 ++ src/claws/add.test.ts | 190 +++++++++ src/claws/add.ts | 82 ++-- src/claws/agent-config-digest.ts | 7 + src/claws/application-schema.test.ts | 2 +- src/claws/bootstrap.test.ts | 35 +- src/claws/export.test.ts | 84 +++- src/claws/export.ts | 14 +- src/claws/legacy-resume.ts | 44 ++ src/claws/lifecycle-config-removal.ts | 6 +- src/claws/lifecycle-delete-support.ts | 4 + src/claws/lifecycle.ts | 7 +- src/claws/openclaw-profile.test.ts | 115 ++++- src/claws/openclaw-profile.ts | 88 +++- src/claws/provenance-runtime-read.ts | 193 +++++++++ src/claws/provenance-schema-version.test.ts | 135 ++++++ src/claws/provenance-schema-version.ts | 55 +++ src/claws/provenance.test.ts | 2 +- src/claws/provenance.ts | 155 +++---- src/claws/reader.ts | 30 +- src/claws/schema.ts | 79 +++- .../tool-policy-runtime.integration.test.ts | 392 ++++++++++++++++++ src/claws/tool-policy-runtime.test.ts | 28 ++ src/claws/tool-policy-runtime.ts | 168 ++++++++ src/claws/tool-profile-consent.test.ts | 152 +++++++ src/claws/tool-profile-consent.ts | 98 +++++ src/claws/types.ts | 1 + src/claws/update-capability-changes.test.ts | 65 +++ src/claws/update-capability-changes.ts | 52 ++- src/cli/claws-cli-legacy-resume.test.ts | 160 +++++++ src/cli/claws-cli-legacy-resume.ts | 30 ++ src/cli/claws-cli.runtime.ts | 107 ++++- src/cli/claws-cli.test.ts | 14 + src/cli/claws-update-cli.runtime.ts | 4 +- src/cli/gateway-cli/run-loop.test.ts | 1 + src/commands/agent.runtime-config.test.ts | 1 + src/config/runtime-snapshot.ts | 14 + src/plugins/tool-descriptor-cache.test.ts | 1 + src/state/openclaw-state-db-cache.ts | 33 ++ src/state/openclaw-state-db.ts | 16 +- 63 files changed, 2596 insertions(+), 226 deletions(-) create mode 100755 src/claws/add-plan-helpers.ts create mode 100755 src/claws/add.test.ts create mode 100644 src/claws/agent-config-digest.ts create mode 100755 src/claws/legacy-resume.ts create mode 100644 src/claws/provenance-runtime-read.ts create mode 100644 src/claws/provenance-schema-version.test.ts create mode 100644 src/claws/provenance-schema-version.ts create mode 100644 src/claws/tool-policy-runtime.integration.test.ts create mode 100644 src/claws/tool-policy-runtime.test.ts create mode 100644 src/claws/tool-policy-runtime.ts create mode 100644 src/claws/tool-profile-consent.test.ts create mode 100644 src/claws/tool-profile-consent.ts create mode 100755 src/cli/claws-cli-legacy-resume.test.ts create mode 100755 src/cli/claws-cli-legacy-resume.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 7ddbfed0eea7..65bc523e47e7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"db7aab74b421a7ed47dac3fd71ca80190d2df3102a1a890b0d72323ed155d11d","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"31bcb6e7b685c67391cc16a005f4ff55ffd97d382a10f4b1796ddf2fd303a308","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 0d5772c872b3..d8f24331b9b5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"f2752fd306b35b124b0cd32b732711be0f9831f10d13f9fcaacd4ecb39cce9e3","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"108af57a4b4bc57c70912fc40319876bb6ec994cce59d2b9c991980e18c4f80f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 11be50f97c47..84a99d349f4f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"6fc88598c472783c5cd49a44a55c7ea918438eb540837772d68bbe6928e68495","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"dd255204bf11dccfe9d866e7a6725935bfead7378a52ddfa6b60b9a58c0cdf76","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 58a65efdfcb3..2c34141dd292 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"c9cc79970dbfef3cf73c2abaa966daa4206b25cc87d43562a32eb44934d6bb0c","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"4bc2ad950266148cfbb2acc7996de31401d88dd5f80ea28754e76f3bd66ef0d2","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 84dc84757f9c..bb54e32fc363 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"4cd42b2807c10408d0067265697f27a18bce6a1de9a4988e1a2e879f87c74598","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"f0be441226a760f75dc1634f93d7f28586a0b18f690e2dd0f87c5360f2cb7f03","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 5411d8f1feaf..c6937ba3e11d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"dc48f51312725277ee6004c6bfe43d42d16dae0e7e0fe9371a66f4b7a859df76","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"8bf7d1fd1e21861fd472365e0ab10ca811800e1b0cb9e6aac0e4d6a41613a9a2","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index cdc70252ada3..2bbe61f4c659 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"0dff4add5bfb93c182a0c0b3f3ee471e7fbf69d9d0e1f6a342668451a4620c18","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"d430c3def27acb48a5356dafac17b89b0cbfcb1d3f959136b8c7ae82586d87df","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index ffe887e043f0..588ab76e1ecb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"d2a7bb51e14d60bcc1f623f9fabf8baa3d4d2b253f8a643b331e10e20c0ca9ac","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"aa9a880fa3abde607a3664e55cf6bd35966528d436c0438aa3c1b7607c8e46e7","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 8179da6eebdd..a97ddd3aecfe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"15b6006cb8edd215e6df21c5c8250a25635661b6633b0a858c50ed019554aa50","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"4c667b7e88cd752c294bcc731bc157c1ccb2a7aa4afc58f4013409191f3a9b28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index cc70fb436463..2c7c6e047633 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"751c9fe121607e061b2eb3e1c57fc854d650ab583873979746cf25637c551669","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"027b2280171324b628865fb4cdf9f72c13c059385a2fbec5b650c69d66beecdc","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 671d009f642a..c746dcca3132 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"5824cd4a9e5b2242177cfa79040d2bba2eb22b90cb1d2cf0d257d2088b47a6f3","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"94ee499e1ea0653d3e7daed7c6aaa2db8d7c3026433eba62b19a5f6cda93028b","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 6020be498209..cfe816da9801 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"8307497b019c13f8ce6d3159a07a4df516d6f84b7219b2ab1666ceeb3481fa61","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"69fe3479e42b74771fb86d228264e8e19a3a16e1a07df4f89499111c2df2bc13","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index a698cdbe0cfb..0bb25fd1bf8f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"52e9b62ba55034c21c4673e2acba9c6a367d75c46e92cff698f2a6720008c17e","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"f058be9cbffbcb6e1dafe69cbc701c7cc06d1ffc90323256685e31b111f91193","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index e23be8087c67..3eb80e8bec0e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"0da095103f904be5e77d287c93a739b33f192c96e0f5334571ad46245a9ecba5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"e7ac61e29d41e43a4c780831a8aa0e5bed10e47630a36879f2555216eb1cce54","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 76bcfd003c9c..1e1d21020f8a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"244eeb2fb3601d42e421c46f46e21432c954ddfdacc928fd6a9baa6a9ef6deba","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"12f6874f2b2c49f6fa22b91acdae820a725564d381a5b26dc4b2b4e65d0ef803","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 37801fe08deb..b7d31acd4088 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"b9c11e37dd3647431225b8fdd97d6402074a741c80f2fa3e54745bade5fcc6b7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"f76d5908b66bb49851cccc530dc63fc5f3352b327963f1091a0f34fe9ccc693b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 9602bafef2ec..f8e224cc6ced 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"307513192612557813825c129772eb642c0f4aac97ba538938dd4f6e9efd60d9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"e9dd8d432098b26f2e102fe41edee7c556558ea9b26a0077da72b0967d6649d2","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/cli/claws.md b/docs/cli/claws.md index d671d91e2f7f..df5ce8b9e640 100644 --- a/docs/cli/claws.md +++ b/docs/cli/claws.md @@ -79,8 +79,7 @@ conflict. schemaVersion: 1 agent: tools: - profile: coding - alsoAllow: [cron] + allow: [read, write, cron] deny: [exec] fs: workspaceOnly: true @@ -101,11 +100,18 @@ Grouped JSON discovers the same conventional profile rather than embedding a second copy of the OpenClaw settings. The remaining schema fragments on this page use JSON, with equivalent keys available in `CLAW.md` frontmatter. -The OpenClaw package profile may select any built-in tool profile registered by -the running OpenClaw version, then refine it with `alsoAllow`, `deny`, and -`tools.fs.workspaceOnly: true`. A Claw cannot set that field to `false` and -weaken host filesystem confinement. `tools.allow` remains available as an -explicit allowlist but cannot be combined with `alsoAllow`. A Claw may also set +The OpenClaw package profile may use an explicit `tools.allow` list or select +any built-in tool profile registered by the running OpenClaw version. The +`coding` and `messaging` profiles include the dynamic `bundle-mcp` selector, so +a Claw that selects either profile must also provide a bounded `tools.allow` +intersection. Name any MCP grants as concrete generated tool names such as +`github__list_issues`; the package cannot freeze `bundle-mcp` itself. + +Profiles can otherwise be refined with `alsoAllow`, `deny`, and +`tools.fs.workspaceOnly: true`. `tools.allow` cannot be combined with +`alsoAllow`; use a standalone allowlist, as above, when the package needs tools +outside its selected profile. A Claw cannot set `workspaceOnly` to `false` and +weaken host filesystem confinement. A Claw may also set `memory.search.enabled`, choose the portable `memory` and `sessions` sources, and opt into cross-conversation memory with `rememberAcrossConversations`. Declaring the `sessions` source requires that opt-in. diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 143ef9edb28b..cd059949228c 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -345,6 +345,7 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ })); vi.mock("../config/runtime-snapshot.js", () => ({ + registerRuntimeConfigSnapshotPreparer: vi.fn(), setRuntimeConfigSnapshot: vi.fn(), })); diff --git a/src/agents/agent-tools.policy.ts b/src/agents/agent-tools.policy.ts index c0316aa79441..aa9f2868bdd7 100644 --- a/src/agents/agent-tools.policy.ts +++ b/src/agents/agent-tools.policy.ts @@ -10,6 +10,10 @@ import { } from "@openclaw/normalization-core/string-normalization"; import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; import { resolveSessionConversation } from "../channels/plugins/session-conversation.js"; +import { + markFrozenClawToolAllowPolicy, + resolveClawToolPolicyConsent, +} from "../claws/tool-policy-runtime.js"; import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; @@ -407,6 +411,17 @@ export function resolveEffectiveToolPolicy(params: { }); const explicitProfileAlsoAllow = resolveExplicitProfileAlsoAllow(agentTools) ?? resolveExplicitProfileAlsoAllow(globalTools); + const agentPolicy = pickSandboxToolPolicy(agentTools); + const clawToolPolicyConsent = resolveClawToolPolicyConsent({ + agentTools, + agentId, + profile, + ownsProfile: profileSource === "agent", + hasAgentAllowlist: (agentPolicy?.allow?.length ?? 0) > 0, + }); + if (clawToolPolicyConsent.frozen) { + markFrozenClawToolAllowPolicy(agentPolicy); + } // Warn affected users about removed implicit grants (#47487), but only when // the active profile/explicit alsoAllow do not already grant those tools. @@ -448,7 +463,7 @@ export function resolveEffectiveToolPolicy(params: { agentId, globalPolicy: pickSandboxToolPolicy(globalTools), globalProviderPolicy: pickSandboxToolPolicy(providerPolicy), - agentPolicy: pickSandboxToolPolicy(agentTools), + agentPolicy, agentProviderPolicy: pickSandboxToolPolicy(agentProviderPolicy), profile, providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile, diff --git a/src/agents/conversation-tool-policy-pipeline.ts b/src/agents/conversation-tool-policy-pipeline.ts index 33e4b1c76030..db7be7f640a5 100644 --- a/src/agents/conversation-tool-policy-pipeline.ts +++ b/src/agents/conversation-tool-policy-pipeline.ts @@ -1,3 +1,4 @@ +import { isFrozenClawToolAllowPolicy } from "../claws/tool-policy-runtime.js"; import type { ResolvedConversationCapabilityProfile } from "./conversation-capability-profile.js"; import { applyToolPolicyPipeline, @@ -25,6 +26,9 @@ function mergePolicyAllowlist( policy: TPolicy | undefined, alsoAllow: readonly string[] | undefined, ): TPolicy | undefined { + if (isFrozenClawToolAllowPolicy(policy)) { + return policy; + } return mergeAlsoAllowPolicy(policy, alsoAllow ? [...alsoAllow] : undefined); } diff --git a/src/agents/tool-policy-pipeline.test.ts b/src/agents/tool-policy-pipeline.test.ts index 6ff5ab4a56d9..5163eda2010e 100644 --- a/src/agents/tool-policy-pipeline.test.ts +++ b/src/agents/tool-policy-pipeline.test.ts @@ -1,6 +1,7 @@ // Tool policy pipeline tests cover profile/allowlist filtering, diagnostics, // warning dedupe, and plugin-aware policy application. import { beforeEach, describe, expect, test, vi } from "vitest"; +import { markFrozenClawToolAllowPolicy } from "../claws/tool-policy-runtime.js"; import { buildDeclaredToolAllowlistContext } from "./tool-policy-declared-context.js"; import { applyToolPolicyPipeline, @@ -81,6 +82,33 @@ describe("tool-policy-pipeline", () => { expect(names).toEqual(["plugin_tool"]); }); + test("can freeze an allowlist entry against a later plugin-id collision", () => { + const tools = [{ name: "read" }, { name: "future_tool" }]; + const toolMeta = (tool: DummyTool) => + tool.name === "future_tool" ? { pluginId: "read" } : undefined; + const apply = (frozen: boolean) => { + const policy = { allow: ["read"] }; + if (frozen) { + markFrozenClawToolAllowPolicy(policy); + } + return applyToolPolicyPipeline({ + tools: asPolicyTools(tools), + toolMeta, + warn: () => {}, + steps: [ + { + policy, + label: "agent tools.allow", + stripPluginOnlyAllowlist: true, + }, + ], + }).map((tool) => tool.name); + }; + + expect(apply(false)).toEqual(["future_tool"]); + expect(apply(true)).toEqual(["read"]); + }); + test.each([ { expected: ["exec"], policy: { deny: ["canvas"] } }, { expected: ["canvas", "show_widget"], policy: { allow: ["canvas"] } }, diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index 7bce6cb5edba..8bff5994c51c 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -3,6 +3,7 @@ * stay tied to the layer that introduced them, while plugin groups are * expanded only after unknown core/plugin entries are classified. */ +import { isFrozenClawToolAllowPolicy } from "../claws/tool-policy-runtime.js"; import { filterToolsByPolicy } from "./agent-tools.policy.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { isKnownCoreToolId } from "./tool-catalog.js"; @@ -160,6 +161,7 @@ export function applyToolPolicyPipeline(params: } let policy: ToolPolicyLike | undefined = step.policy; + const frozenAllow = isFrozenClawToolAllowPolicy(policy); if (step.stripPluginOnlyAllowlist) { // Plugin-only allowlists are valid for deferred tools; warn only for entries that cannot match. const resolved = analyzeAllowlistByToolType( @@ -206,7 +208,13 @@ export function applyToolPolicyPipeline(params: policy = resolved.policy; } - const expanded = expandPolicyWithPluginGroups(policy, pluginGroups); + const expanded = + frozenAllow && policy + ? { + allow: policy.allow, + deny: expandPolicyWithPluginGroups({ deny: policy.deny }, pluginGroups)?.deny, + } + : expandPolicyWithPluginGroups(policy, pluginGroups); if (!expanded) { continue; } diff --git a/src/claws/add-plan-helpers.ts b/src/claws/add-plan-helpers.ts new file mode 100755 index 000000000000..a2ff5970ad4a --- /dev/null +++ b/src/claws/add-plan-helpers.ts @@ -0,0 +1,44 @@ +import { stableStringify } from "@openclaw/normalization-core"; +import type { AgentConfig } from "../config/types.agents.js"; +import type { ClawInstallStatus } from "./provenance.js"; +import type { ClawAddPlan } from "./types.js"; + +export function hasUnsupportedMutationActions(plan: ClawAddPlan): boolean { + return plan.actions.some( + (action) => + ![ + "agent", + "workspace", + "bootstrap", + "workspaceFile", + "package", + "mcpServer", + "cronJob", + ].includes(action.kind), + ); +} + +export function planWithPackageActions( + plan: ClawAddPlan, + predicate: (action: ClawAddPlan["actions"][number]) => boolean, +): ClawAddPlan { + return { + ...plan, + actions: plan.actions.filter((action) => action.kind !== "package" || predicate(action)), + }; +} + +export function statusAtLeast(status: ClawInstallStatus, phase: ClawInstallStatus): boolean { + const order: Record = { + pending: 0, + partial: 0, + workspace_ready: 1, + config_committed: 2, + complete: 3, + }; + return order[status] >= order[phase]; +} + +export function sameCommittedAgent(existingAgent: AgentConfig, plan: ClawAddPlan): boolean { + return stableStringify(existingAgent) === stableStringify(plan.agent.config); +} diff --git a/src/claws/add.test.ts b/src/claws/add.test.ts new file mode 100755 index 000000000000..14685401e66e --- /dev/null +++ b/src/claws/add.test.ts @@ -0,0 +1,190 @@ +import { mkdir } from "node:fs/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { applyClawAddPlan } from "./add.js"; +import { persistClawInstallRecord, readClawInstallRecord } from "./provenance.js"; +import { makeProvenancePlan, stateEnv } from "./provenance.test-helpers.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +describe("Claw add legacy plan resume", () => { + it("replaces committed legacy config before upgrading v1 plan identity", async () => { + const root = tempDirs.make("openclaw-claw-add-v1-resume-"); + const env = stateEnv(root); + const { plan } = await makeProvenancePlan(root, { + schemaVersion: 1, + agent: { id: "worker" }, + }); + const legacyPlan = { + ...plan, + planIntegrity: "sha256:legacy-plan", + agent: { + ...plan.agent, + config: { + ...plan.agent.config, + tools: { profile: "coding" as const }, + }, + }, + }; + const boundedPlan = { + ...plan, + planIntegrity: "sha256:bounded-plan", + agent: { + ...plan.agent, + config: { + ...plan.agent.config, + tools: { profile: "full" as const, allow: ["read"] }, + }, + }, + }; + await mkdir(boundedPlan.agent.workspace, { recursive: true }); + persistClawInstallRecord(legacyPlan, { env, status: "workspace_ready", nowMs: 1 }); + openOpenClawStateDatabase({ env }) + .db /* sqlite-allow-raw: test-only downgrade simulates an interrupted v1 add. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.v1", "worker"); + const legacyRecord = readClawInstallRecord("worker", { env }); + if (!legacyRecord) { + throw new Error("expected legacy install record"); + } + let config: OpenClawConfig = { + agents: { + entries: { + worker: Object.fromEntries( + Object.entries(legacyPlan.agent.config).filter(([key]) => key !== "id"), + ), + }, + }, + }; + + const result = await applyClawAddPlan(boundedPlan, { + env, + consentPlanIntegrity: legacyPlan.planIntegrity, + resumeRecord: legacyRecord, + resumePlan: legacyPlan, + commitConfig: async (transform) => { + config = transform(config); + }, + seedPackageBootstrap: async () => undefined, + createWorkspaceFiles: async () => [], + installPackages: async () => [], + installMcpServers: async () => [], + installCronJobs: async () => [], + }); + + expect(result.status).toBe("complete"); + expect(config.agents?.entries?.worker).toMatchObject({ + tools: { profile: "full", allow: ["read"] }, + }); + expect(readClawInstallRecord("worker", { env })).toMatchObject({ + schemaVersion: "openclaw.clawInstallRecord.v2", + planIntegrity: boundedPlan.planIntegrity, + status: "complete", + }); + }); + + it("retries after v1 promotion fails behind the bounded config commit", async () => { + const root = tempDirs.make("openclaw-claw-add-v1-promotion-retry-"); + const env = stateEnv(root); + const { plan } = await makeProvenancePlan(root, { + schemaVersion: 1, + agent: { id: "worker" }, + }); + const legacyPlan = { + ...plan, + planIntegrity: "sha256:legacy-plan", + agent: { + ...plan.agent, + config: { + ...plan.agent.config, + tools: { profile: "coding" as const }, + }, + }, + }; + const boundedPlan = { + ...plan, + planIntegrity: "sha256:bounded-plan", + agent: { + ...plan.agent, + config: { + ...plan.agent.config, + tools: { profile: "full" as const, allow: ["read"] }, + }, + }, + }; + await mkdir(boundedPlan.agent.workspace, { recursive: true }); + persistClawInstallRecord(legacyPlan, { env, status: "workspace_ready", nowMs: 1 }); + openOpenClawStateDatabase({ env }) + .db /* sqlite-allow-raw: test-only downgrade simulates an interrupted v1 add. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.v1", "worker"); + const legacyRecord = readClawInstallRecord("worker", { env }); + if (!legacyRecord) { + throw new Error("expected legacy install record"); + } + let config: OpenClawConfig = { + agents: { + entries: { + worker: Object.fromEntries( + Object.entries(legacyPlan.agent.config).filter(([key]) => key !== "id"), + ), + }, + }, + }; + const commitConfig = async (transform: (config: OpenClawConfig) => OpenClawConfig) => { + config = transform(config); + }; + const dependencies = { + env, + consentPlanIntegrity: legacyPlan.planIntegrity, + resumeRecord: legacyRecord, + resumePlan: legacyPlan, + commitConfig, + seedPackageBootstrap: async () => undefined, + createWorkspaceFiles: async () => [], + installPackages: async () => [], + installMcpServers: async () => [], + installCronJobs: async () => [], + }; + const persistRecord = vi + .fn() + .mockImplementationOnce((...args) => persistClawInstallRecord(...args)) + .mockImplementationOnce(() => { + throw new Error("injected v1 promotion failure"); + }); + + const first = await applyClawAddPlan(boundedPlan, { ...dependencies, persistRecord }); + + expect(first).toMatchObject({ + status: "partial", + configCommitted: true, + error: { message: "injected v1 promotion failure" }, + }); + expect(config.agents?.entries?.worker).toMatchObject({ + tools: { profile: "full", allow: ["read"] }, + }); + expect(readClawInstallRecord("worker", { env })).toMatchObject({ + schemaVersion: "openclaw.clawInstallRecord.v1", + planIntegrity: legacyPlan.planIntegrity, + status: "workspace_ready", + }); + + const second = await applyClawAddPlan(boundedPlan, dependencies); + + expect(second.status).toBe("complete"); + expect(readClawInstallRecord("worker", { env })).toMatchObject({ + schemaVersion: "openclaw.clawInstallRecord.v2", + planIntegrity: boundedPlan.planIntegrity, + status: "complete", + }); + }); +}); diff --git a/src/claws/add.ts b/src/claws/add.ts index 7072f1e8e386..4f6910e9dbaa 100644 --- a/src/claws/add.ts +++ b/src/claws/add.ts @@ -2,7 +2,7 @@ import type { Stats } from "node:fs"; import { lstat, mkdir, rmdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage } from "@openclaw/normalization-core"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; import { listAgentEntries } from "../agents/agent-scope.js"; import { transformConfigFileWithRetry } from "../config/config.js"; @@ -14,6 +14,12 @@ import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { resolveUserPath } from "../utils.js"; +import { + hasUnsupportedMutationActions, + planWithPackageActions, + sameCommittedAgent, + statusAtLeast, +} from "./add-plan-helpers.js"; import { ClawBootstrapWriteError, seedClawPackageBootstrap } from "./bootstrap.js"; import { ClawCronInstallError, @@ -21,6 +27,7 @@ import { type ClawCronGateway, type PersistedClawCronRef, } from "./cron.js"; +import { replaceLegacyCommittedAgent } from "./legacy-resume.js"; import { ClawMcpInstallError, installClawMcpServers, @@ -47,6 +54,8 @@ export const CLAW_ADD_RESULT_SCHEMA_VERSION = "openclaw.clawAddResult.v1" as con type ConfigCommit = (transform: (config: OpenClawConfig) => OpenClawConfig) => Promise; type ClawAddApplyOptions = OpenClawStateDatabaseOptions & { consentPlanIntegrity?: string; + resumeRecord?: PersistedClawInstall; + resumePlan?: ClawAddPlan; commitConfig?: ConfigCommit; persistRecord?: typeof persistClawInstallRecord; deleteRecord?: typeof deleteClawInstallRecord; @@ -93,42 +102,6 @@ type ClawAddResult = { }; }; -function hasUnsupportedMutationActions(plan: ClawAddPlan): boolean { - return plan.actions.some( - (action) => - ![ - "agent", - "workspace", - "bootstrap", - "workspaceFile", - "package", - "mcpServer", - "cronJob", - ].includes(action.kind), - ); -} - -function planWithPackageActions( - plan: ClawAddPlan, - predicate: (action: ClawAddPlan["actions"][number]) => boolean, -): ClawAddPlan { - return { - ...plan, - actions: plan.actions.filter((action) => action.kind !== "package" || predicate(action)), - }; -} - -function statusAtLeast(status: ClawInstallStatus, phase: ClawInstallStatus): boolean { - const order: Record = { - pending: 0, - partial: 0, - workspace_ready: 1, - config_committed: 2, - complete: 3, - }; - return order[status] >= order[phase]; -} - function markInstallStatus( agentId: string, status: ClawInstallStatus, @@ -152,10 +125,6 @@ function clearUnownedInstallRecord( }); } -function sameCommittedAgent(existingAgent: AgentConfig, plan: ClawAddPlan): boolean { - return stableStringify(existingAgent) === stableStringify(plan.agent.config); -} - function workspacePathKey(value: string): string { return process.platform === "win32" ? normalizeWindowsPathForComparison(value) : value; } @@ -220,7 +189,7 @@ export async function applyClawAddPlan( "This build cannot add one or more declared Claw component kinds.", ); } - if (options.consentPlanIntegrity !== plan.planIntegrity) { + if (options.consentPlanIntegrity !== (options.resumePlan?.planIntegrity ?? plan.planIntegrity)) { throw new ClawAddMutationError( "plan_integrity_mismatch", "Consent does not match the current Claw add plan; run add --dry-run again.", @@ -230,7 +199,13 @@ export async function applyClawAddPlan( const persistRecord = options.persistRecord ?? persistClawInstallRecord; let installRecord: PersistedClawInstall; try { - installRecord = persistRecord(plan, { ...options, status: "pending" }); + installRecord = persistRecord(plan, { + ...options, + status: "pending", + expectedExistingRecord: options.resumeRecord, + expectedExistingPlan: options.resumePlan, + deferLegacyPlanUpgrade: options.resumePlan !== undefined, + }); } catch (error) { throw new ClawAddMutationError("provenance_failed", (error as Error).message); } @@ -470,6 +445,19 @@ export async function applyClawAddPlan( configCommitted = true; return config; } + const nextConfig = replaceLegacyCommittedAgent({ + config: configWithPreservedAgents, + agents: agentsToPreserve, + normalizedAgentId, + plan, + resumePlan: options.resumePlan, + resumeRecord: options.resumeRecord, + matchesPlan: sameCommittedAgent, + }); + if (nextConfig) { + configCommitted = true; + return nextConfig; + } throw new ClawAddMutationError( "agent_id_collision", "Agent " + JSON.stringify(plan.agent.finalId) + " was created after planning.", @@ -496,6 +484,14 @@ export async function applyClawAddPlan( configCommitted = true; return nextConfig; }); + if (options.resumePlan && installRecord.schemaVersion === "openclaw.clawInstallRecord.v1") { + installRecord = persistRecord(plan, { + ...options, + status: "pending", + expectedExistingRecord: options.resumeRecord, + expectedExistingPlan: options.resumePlan, + }); + } markInstallStatus( plan.agent.finalId, "config_committed", diff --git a/src/claws/agent-config-digest.ts b/src/claws/agent-config-digest.ts new file mode 100644 index 000000000000..1788f8436fbb --- /dev/null +++ b/src/claws/agent-config-digest.ts @@ -0,0 +1,7 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; +import type { AgentConfig } from "../config/types.agents.js"; + +export function digestClawAgentConfig(agent: AgentConfig): string { + return `sha256:${createHash("sha256").update(stableStringify(agent)).digest("hex")}`; +} diff --git a/src/claws/application-schema.test.ts b/src/claws/application-schema.test.ts index 7dbe60799e67..77fe3f74c213 100644 --- a/src/claws/application-schema.test.ts +++ b/src/claws/application-schema.test.ts @@ -50,7 +50,7 @@ describe("Claw application schema v1", () => { expect( parseClawOpenClawProfile({ schemaVersion: 1, - agent: { tools: { profile: "coding" } }, + agent: { tools: { profile: "coding", allow: ["read"] } }, extensions: [extension], }), ).toMatchObject({ diff --git a/src/claws/bootstrap.test.ts b/src/claws/bootstrap.test.ts index b090be92bdc6..d993546930ee 100644 --- a/src/claws/bootstrap.test.ts +++ b/src/claws/bootstrap.test.ts @@ -602,21 +602,24 @@ describe("package-root BOOTSTRAP.md", () => { }); }); - it("reserves root BOOTSTRAP.md for the native seed-once lifecycle", () => { - const result = parseClawManifest({ - schemaVersion: 1, - agent: { id: "bootstrap-worker" }, - workspace: { - files: [{ source: "assets/BOOTSTRAP.md", path: "BOOTSTRAP.md" }], - }, - }); + it.each(["BOOTSTRAP.md", "BOOTSTRAP.md/notes.md", "bootstrap.md/notes.md"])( + "reserves %s for the native seed-once lifecycle", + (path) => { + const result = parseClawManifest({ + schemaVersion: 1, + agent: { id: "bootstrap-worker" }, + workspace: { + files: [{ source: "assets/BOOTSTRAP.md", path }], + }, + }); - expect(result.ok).toBe(false); - expect(result.diagnostics).toContainEqual( - expect.objectContaining({ - path: "$.workspace.files[0].path", - message: expect.stringContaining("native seed-once lifecycle"), - }), - ); - }); + expect(result.ok).toBe(false); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + path: "$.workspace.files[0].path", + message: expect.stringContaining("native seed-once lifecycle"), + }), + ); + }, + ); }); diff --git a/src/claws/export.test.ts b/src/claws/export.test.ts index 1c4519f1f95c..2c84f81c5159 100644 --- a/src/claws/export.test.ts +++ b/src/claws/export.test.ts @@ -11,7 +11,11 @@ import { applyClawAddPlan } from "./add.js"; import { exportClawAgent } from "./export.js"; import { buildClawAddPlan } from "./lifecycle.js"; import { installClawMcpServers } from "./mcp.js"; -import { persistClawPackageRef, updateClawInstallRecordStatus } from "./provenance.js"; +import { + persistClawPackageRef, + updateClawInstallRecord, + updateClawInstallRecordStatus, +} from "./provenance.js"; import { readClawManifestFile } from "./reader.js"; import { parseClawManifest } from "./schema.js"; import type { ClawOpenClawProfile, ClawSourceIdentity } from "./types.js"; @@ -114,7 +118,7 @@ async function installedFixture( schemaVersion: 1, agent: { tools: { - profile: "coding", + profile: "minimal", alsoAllow: ["cron"], deny: ["exec"], fs: { workspaceOnly: true }, @@ -218,6 +222,72 @@ async function installedFixture( } describe("exportClawAgent", () => { + it("freezes a legacy named profile before exporting it", async () => { + const fixture = await installedFixture(); + fixture.config.agents!.entries!.worker!.tools = { + profile: "minimal", + deny: ["exec"], + }; + updateClawInstallRecord( + { + ...fixture.plan, + agent: { + ...fixture.plan.agent, + config: { + id: "worker", + ...fixture.config.agents!.entries!.worker!, + workspace: fixture.plan.agent.workspace, + }, + }, + }, + { env: fixture.env }, + ); + + const result = await exportClawAgent("worker", join(fixture.root, "legacy-profile-export"), { + env: fixture.env, + config: fixture.config, + packageDeps: fixture.packageDeps, + sourceMcpServers: fixture.sourceMcpServers, + }); + + expect(result.openClawProfile?.agent.tools).toMatchObject({ + profile: "full", + allow: expect.arrayContaining(["session_status"]), + deny: ["exec"], + }); + expect(result.openClawProfile?.agent.tools).not.toHaveProperty("alsoAllow"); + }); + + it("rejects export of an unbounded legacy full profile", async () => { + const fixture = await installedFixture(); + fixture.config.agents!.entries!.worker!.tools = { profile: "full" }; + updateClawInstallRecord( + { + ...fixture.plan, + agent: { + ...fixture.plan.agent, + config: { + id: "worker", + ...fixture.config.agents!.entries!.worker!, + workspace: fixture.plan.agent.workspace, + }, + }, + }, + { env: fixture.env }, + ); + + await expect( + exportClawAgent("worker", join(fixture.root, "unbounded-profile-export"), { + env: fixture.env, + config: fixture.config, + packageDeps: fixture.packageDeps, + sourceMcpServers: fixture.sourceMcpServers, + }), + ).rejects.toMatchObject({ + code: "tool_profile_consent_required", + }); + }); + it("writes a grouped package from one installed agent", async () => { const fixture = await installedFixture({ withPackage: true }); expect(fixture.plan.agent.config.memory?.search).toEqual({ @@ -287,10 +357,7 @@ describe("exportClawAgent", () => { schemaVersion: 1, agent: { tools: { - profile: "coding", - alsoAllow: ["cron"], - deny: ["exec"], - fs: { workspaceOnly: true }, + ...fixture.plan.agent.config.tools, }, memory: { search: { @@ -320,11 +387,12 @@ describe("exportClawAgent", () => { expect(exported.manifest.metadata).toEqual({}); expect(exported.openClawProfile).toMatchObject({ schemaVersion: 1, - agent: { tools: { profile: "coding" } }, + agent: { tools: fixture.plan.agent.config.tools }, }); + expect(exported.openClawProfile?.agent.tools).not.toHaveProperty("alsoAllow"); expect(exported.manifest.workspace.bootstrapFiles).not.toHaveProperty("SOUL.md"); await expect(readFile(join(out, "profiles", "openclaw.yml"), "utf8")).resolves.toContain( - "profile: coding", + "profile: full", ); await expect(readFile(join(out, "workspace", "SOUL.md"), "utf8")).rejects.toThrow(); }); diff --git a/src/claws/export.ts b/src/claws/export.ts index bcf1784fe854..6c621bde25d3 100644 --- a/src/claws/export.ts +++ b/src/claws/export.ts @@ -20,6 +20,7 @@ import { readClawManifestFile } from "./reader.js"; import { isPortableClawAvatar } from "./schema-portability.js"; import { parseClawManifest, parseClawOpenClawProfile } from "./schema.js"; import { MAX_CLAW_MANIFEST_BYTES, MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js"; +import { materializeClawToolProfile } from "./tool-profile-consent.js"; import { CLAW_BOOTSTRAP_FILE_NAMES, CLAW_OUTPUT_STABILITY, @@ -86,13 +87,24 @@ function portableOpenClawProfile( agent: AgentConfig, extensions: ClawOpenClawExtension[], ): ClawOpenClawProfile | undefined { - const tools = { + const configuredTools = { ...(agent.tools?.profile ? { profile: agent.tools.profile } : {}), ...(agent.tools?.allow?.length ? { allow: agent.tools.allow } : {}), ...(agent.tools?.alsoAllow?.length ? { alsoAllow: agent.tools.alsoAllow } : {}), ...(agent.tools?.deny?.length ? { deny: agent.tools.deny } : {}), ...(agent.tools?.fs?.workspaceOnly === true ? { fs: { workspaceOnly: true as const } } : {}), }; + let tools: NonNullable = configuredTools; + if (configuredTools.profile || configuredTools.allow?.length) { + try { + tools = materializeClawToolProfile({ tools: configuredTools }).tools ?? {}; + } catch (error) { + throw new ClawExportError( + "tool_profile_consent_required", + `Could not freeze the exported tool profile: ${(error as Error).message}`, + ); + } + } const settings = { ...(agent.groupChat?.mentionPatterns?.length ? { groupChat: { mentionPatterns: agent.groupChat.mentionPatterns } } diff --git a/src/claws/legacy-resume.ts b/src/claws/legacy-resume.ts new file mode 100755 index 000000000000..2066f8c67393 --- /dev/null +++ b/src/claws/legacy-resume.ts @@ -0,0 +1,44 @@ +import type { AgentConfig, OpenClawConfig } from "../config/config.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { PersistedClawInstall } from "./provenance.js"; +import type { ClawAddPlan } from "./types.js"; + +export function replaceLegacyCommittedAgent(params: { + config: OpenClawConfig; + agents: AgentConfig[]; + normalizedAgentId: string; + plan: ClawAddPlan; + resumePlan?: ClawAddPlan; + resumeRecord?: PersistedClawInstall; + matchesPlan: (agent: AgentConfig, plan: ClawAddPlan) => boolean; +}): OpenClawConfig | undefined { + if ( + !params.resumePlan || + params.resumeRecord?.schemaVersion !== "openclaw.clawInstallRecord.v1" || + params.resumeRecord.status === "complete" + ) { + return undefined; + } + const existingAgent = params.agents.find( + (agent) => normalizeAgentId(agent.id) === params.normalizedAgentId, + ); + if (!existingAgent || !params.matchesPlan(existingAgent, params.resumePlan)) { + return undefined; + } + return { + ...params.config, + agents: { + ...params.config.agents, + entries: Object.fromEntries( + params.agents.map((agent) => { + const replacement = + normalizeAgentId(agent.id) === params.normalizedAgentId + ? params.plan.agent.config + : agent; + const { id, ...entry } = replacement; + return [id, entry]; + }), + ), + }, + }; +} diff --git a/src/claws/lifecycle-config-removal.ts b/src/claws/lifecycle-config-removal.ts index d34445c35dba..a7e4cc83c51f 100644 --- a/src/claws/lifecycle-config-removal.ts +++ b/src/claws/lifecycle-config-removal.ts @@ -2,13 +2,13 @@ import { createHash } from "node:crypto"; import { stableStringify } from "@openclaw/normalization-core"; import { listAgentEntries } from "../agents/agent-scope.js"; import { getRuntimeConfig } from "../config/config.js"; -import type { AgentConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { AgentConfigPreconditionError, deleteAgentConfigEntry, } from "../gateway/server-methods/agents-config-mutations.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { digestClawAgentConfig } from "./agent-config-digest.js"; import { deletionEffects, type ClawCleanupTargets, @@ -17,9 +17,7 @@ import { export type ConfigCommit = (transform: (config: OpenClawConfig) => OpenClawConfig) => Promise; -export function digestClawAgentConfig(agent: AgentConfig): string { - return `sha256:${createHash("sha256").update(stableStringify(agent)).digest("hex")}`; -} +export { digestClawAgentConfig } from "./agent-config-digest.js"; export function digestClawAgentRemovalSurface(config: OpenClawConfig, agentId: string): string { const normalizedId = normalizeAgentId(agentId); diff --git a/src/claws/lifecycle-delete-support.ts b/src/claws/lifecycle-delete-support.ts index dde6a6060727..0942a7f3602c 100644 --- a/src/claws/lifecycle-delete-support.ts +++ b/src/claws/lifecycle-delete-support.ts @@ -30,6 +30,7 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; +import { deleteCachedClawInstallSchemaVersion } from "./provenance-runtime-read.js"; import type { PersistedClawInstall } from "./provenance.js"; import type { PersistedClawWorkspaceFile } from "./workspace.js"; @@ -482,4 +483,7 @@ export function releaseClawRemoveRows( .run(agentId); } }, options); + if (complete) { + deleteCachedClawInstallSchemaVersion(agentId, options); + } } diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts index e096c0d829cd..9411ba601dca 100644 --- a/src/claws/lifecycle.ts +++ b/src/claws/lifecycle.ts @@ -12,6 +12,7 @@ import { findClawExtensionPackageCollisions, planClawExtensions } from "./applic import { digestClawMcpServer } from "./mcp.js"; import { clawManifestWorkspaceConflictsWithPath } from "./schema.js"; import { MAX_MANAGED_FILE_BYTES, MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js"; +import { materializeClawToolProfile } from "./tool-profile-consent.js"; import { CLAW_ADD_PLAN_SCHEMA_VERSION, CLAW_BOOTSTRAP_FILE_NAMES, @@ -192,6 +193,7 @@ export async function buildClawAddPlan(params: { packageBootstrap?: ClawWorkspaceSourceSnapshot; includePackageBootstrap?: boolean; openClawProfile?: ClawOpenClawProfile; + reconstructLegacyDynamicToolProfilePlan?: boolean; source: ClawSourceIdentity; diagnostics?: ClawDiagnostic[]; context?: ClawAddPlanContext; @@ -236,9 +238,12 @@ export async function buildClawAddPlan(params: { const existingAgentIds = new Set(context.existingAgentIds ?? []); const agentBlocked = existingAgentIds.has(finalId); const openClawAgentSettings = params.openClawProfile?.agent ?? {}; + const persistedOpenClawAgentSettings = params.reconstructLegacyDynamicToolProfilePlan + ? openClawAgentSettings + : materializeClawToolProfile(openClawAgentSettings); const agentConfig: ClawAddPlan["agent"]["config"] = { ...params.manifest.agent, - ...openClawAgentSettings, + ...persistedOpenClawAgentSettings, id: finalId, workspace, }; diff --git a/src/claws/openclaw-profile.test.ts b/src/claws/openclaw-profile.test.ts index 283d9838cd75..2b0b0fff71dc 100644 --- a/src/claws/openclaw-profile.test.ts +++ b/src/claws/openclaw-profile.test.ts @@ -14,7 +14,7 @@ describe("OpenClaw profile schema", () => { agent: { tools: { profile: "coding", - alsoAllow: ["cron"], + allow: ["read", "github__list_issues"], deny: ["exec"], fs: { workspaceOnly: true }, }, @@ -31,6 +31,15 @@ describe("OpenClaw profile schema", () => { expect(result.ok).toBe(true); }); + it("accepts a full profile only with a bounded allowlist", () => { + expect( + parseClawOpenClawProfile({ + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read", "write"] } }, + }).ok, + ).toBe(true); + }); + it("rejects disabled host filesystem confinement", () => { const result = parseClawOpenClawProfile({ schemaVersion: 1, @@ -61,6 +70,17 @@ describe("OpenClaw profile schema", () => { it("rejects invalid profile policy", () => { for (const agent of [ { tools: { profile: "future-profile" } }, + { tools: { profile: "full" } }, + { tools: { profile: "coding" } }, + { tools: { profile: "messaging" } }, + { tools: { profile: "coding", allow: ["bundle-mcp"] } }, + { tools: { allow: ["bundle-mcp"] } }, + { tools: { allow: ["*"] } }, + { tools: { profile: "coding", allow: ["tts"] } }, + { tools: { profile: "coding", allow: ["read", "tts"] } }, + { tools: { alsoAllow: ["read"] } }, + { tools: { alsoAllow: ["group:plugins"] } }, + { tools: { alsoAllow: ["GROUP:PLUGINS"] } }, { tools: { allow: ["read"], alsoAllow: ["write"] } }, { memory: { search: { provider: "openai" } } }, { memory: { search: { sources: ["sessions"] } } }, @@ -98,6 +118,7 @@ describe("OpenClaw profile reader", () => { "agent:", " tools:", " profile: coding", + " allow: [read]", " deny: [exec]", " fs:", " workspaceOnly: true", @@ -111,7 +132,12 @@ describe("OpenClaw profile reader", () => { openClawProfile: { schemaVersion: 1, agent: { - tools: { profile: "coding", deny: ["exec"], fs: { workspaceOnly: true } }, + tools: { + profile: "coding", + allow: ["read"], + deny: ["exec"], + fs: { workspaceOnly: true }, + }, }, }, }); @@ -121,7 +147,7 @@ describe("OpenClaw profile reader", () => { await writeFile( profilePath, - "schemaVersion: 1\nagent:\n tools:\n profile: messaging\n", + "schemaVersion: 1\nagent:\n tools:\n profile: messaging\n allow: [message]\n", "utf8", ); const second = await readClawManifestFile(root); @@ -132,6 +158,80 @@ describe("OpenClaw profile reader", () => { expect(second.source.integrity).not.toBe(first.source.integrity); }); + it.each([ + { toolProfile: "coding", strictOk: false }, + { toolProfile: "minimal", strictOk: true }, + ] as const)( + "loads a legacy dynamic $toolProfile profile through the update migration path", + async ({ toolProfile, strictOk }) => { + const root = tempDirs.make("openclaw-claw-legacy-profile-"); + await mkdir(join(root, "profiles")); + await writeFile( + join(root, "openclaw.claw.json"), + JSON.stringify({ schemaVersion: 1, agent: { id: "triage" } }), + "utf8", + ); + await writeFile( + join(root, "profiles", "openclaw.yml"), + `schemaVersion: 1\nagent:\n tools:\n profile: ${toolProfile}\n`, + "utf8", + ); + + const manifestPath = join(root, "openclaw.claw.json"); + await expect(readClawManifestFile(manifestPath)).resolves.toMatchObject({ ok: strictOk }); + const migrated = await readClawManifestFile(manifestPath, { + allowLegacyDynamicToolProfile: true, + }); + + expect(migrated).toMatchObject({ + ok: true, + openClawProfile: { + agent: { + tools: { + profile: "full", + allow: expect.not.arrayContaining(["bundle-mcp"]), + }, + }, + }, + legacyOpenClawProfile: { + agent: { + tools: { + profile: toolProfile, + }, + }, + }, + }); + }, + ); + + it("requires package authors to bound a legacy full profile before update", async () => { + const root = tempDirs.make("openclaw-claw-legacy-full-profile-"); + await mkdir(join(root, "profiles")); + await writeFile( + join(root, "openclaw.claw.json"), + JSON.stringify({ schemaVersion: 1, agent: { id: "triage" } }), + "utf8", + ); + await writeFile( + join(root, "profiles", "openclaw.yml"), + "schemaVersion: 1\nagent:\n tools:\n profile: full\n", + "utf8", + ); + + const result = await readClawManifestFile(join(root, "openclaw.claw.json"), { + allowLegacyDynamicToolProfile: true, + }); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [ + expect.objectContaining({ + message: expect.stringContaining("bounded explicit allowlist"), + }), + ], + }); + }); + it("rejects a hardlinked profile", async () => { const root = tempDirs.make("openclaw-claw-profile-hardlink-"); await mkdir(join(root, "profiles")); @@ -219,7 +319,7 @@ describe("OpenClaw profile reader", () => { ); await writeFile( join(root, "profiles", "triage.openclaw.yml"), - "schemaVersion: 1\nagent:\n tools:\n profile: coding\n", + "schemaVersion: 1\nagent:\n tools:\n profile: coding\n allow: [read]\n", "utf8", ); @@ -227,7 +327,10 @@ describe("OpenClaw profile reader", () => { expect(result).toMatchObject({ ok: true, - openClawProfile: { schemaVersion: 1, agent: { tools: { profile: "coding" } } }, + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "coding", allow: ["read"] } }, + }, }); if (!result.ok) { throw new Error("expected the deprecated pointer to keep resolving"); @@ -257,7 +360,7 @@ describe("OpenClaw profile reader", () => { ); await writeFile( join(root, "profiles", "openclaw.yml"), - "schemaVersion: 1\nagent:\n tools:\n profile: coding\n", + "schemaVersion: 1\nagent:\n tools:\n profile: coding\n allow: [read]\n", "utf8", ); diff --git a/src/claws/openclaw-profile.ts b/src/claws/openclaw-profile.ts index 33bb5cb8fdab..a35bfe7b5ebc 100644 --- a/src/claws/openclaw-profile.ts +++ b/src/claws/openclaw-profile.ts @@ -1,8 +1,13 @@ // Safe loader for the conventional package-local OpenClaw profile. import { isScalar, parseDocument, visit } from "yaml"; +import type { ToolProfileId } from "../agents/tool-policy-shared.js"; import { FsSafeError, root as fsSafeRoot } from "../infra/fs-safe.js"; import { isSafeClawRelativePath } from "./schema-portability.js"; import { parseClawOpenClawProfile } from "./schema.js"; +import { + materializeClawToolProfile, + resolveClawToolProfileSnapshot, +} from "./tool-profile-consent.js"; import type { ClawDiagnostic, ClawOpenClawProfile } from "./types.js"; const MAX_PROFILE_BYTES = 256 * 1024; @@ -79,6 +84,81 @@ function parseProfileYaml( } } +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function isToolProfileId(value: string): value is ToolProfileId { + return resolveClawToolProfileSnapshot({ profile: value }) !== undefined; +} + +function migrateLegacyDynamicToolProfile(value: unknown): { + value: unknown; + legacyProfile?: ClawOpenClawProfile; +} { + const profile = record(value); + const agent = record(profile?.agent); + const tools = record(agent?.tools); + const toolProfile = tools?.profile; + if ( + !profile || + !agent || + !tools || + typeof toolProfile !== "string" || + !isToolProfileId(toolProfile) || + tools.allow !== undefined + ) { + return { value }; + } + if (toolProfile === "full") { + return { value }; + } + const validationProbe = parseClawOpenClawProfile({ + ...profile, + agent: { + ...agent, + tools: { + ...tools, + profile: "minimal", + }, + }, + }); + if (!validationProbe.ok) { + return { value }; + } + const validatedTools = validationProbe.profile.agent.tools; + if (!validatedTools) { + return { value }; + } + const selection = { + ...validatedTools, + profile: toolProfile, + }; + const legacyProfile: ClawOpenClawProfile = { + ...validationProbe.profile, + agent: { + ...validationProbe.profile.agent, + tools: selection, + }, + }; + const migrated = materializeClawToolProfile( + { tools: selection }, + { allowLegacyDynamicProfile: true }, + ); + return { + value: { + ...profile, + agent: { + ...agent, + tools: migrated.tools, + }, + }, + legacyProfile, + }; +} + async function readProfileFile(packageRoot: string, path: string): Promise { const packageFiles = await fsSafeRoot(packageRoot); const read = await packageFiles.read(path, { @@ -102,10 +182,12 @@ async function readProfileFile(packageRoot: string, path: string): Promise; + allowLegacyDynamicToolProfile?: boolean; }): Promise< | { ok: true; profile?: ClawOpenClawProfile; + legacyProfile?: ClawOpenClawProfile; raw?: Buffer; path?: string; diagnostics?: ClawDiagnostic[]; @@ -193,7 +275,10 @@ export async function readClawOpenClawProfile(params: { if (!yaml.ok) { return yaml; } - const parsed = parseClawOpenClawProfile(yaml.value); + const migration = params.allowLegacyDynamicToolProfile + ? migrateLegacyDynamicToolProfile(yaml.value) + : { value: yaml.value }; + const parsed = parseClawOpenClawProfile(migration.value); if (!parsed.ok) { return { ok: false, @@ -206,6 +291,7 @@ export async function readClawOpenClawProfile(params: { return { ok: true, profile: parsed.profile, + ...(migration.legacyProfile ? { legacyProfile: migration.legacyProfile } : {}), raw, path: declaredPath, ...(diagnostics.length > 0 ? { diagnostics } : {}), diff --git a/src/claws/provenance-runtime-read.ts b/src/claws/provenance-runtime-read.ts new file mode 100644 index 000000000000..ceb0dabfa43a --- /dev/null +++ b/src/claws/provenance-runtime-read.ts @@ -0,0 +1,193 @@ +import type { DatabaseSync } from "node:sqlite"; +import { + assertOpenClawStateDatabaseOwner, + resolveDatabasePath, +} from "../state/openclaw-state-db-maintenance.js"; +import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; +import { + registerOpenClawStateDatabaseLifecycleListener, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; +import { parseClawInstallRecordSchemaVersion } from "./provenance-schema-version.js"; + +type ClawInstallSchemaVersionRead = + | { + kind: "ok"; + schemaVersion: ReturnType; + agentConfigDigest: string; + } + | { kind: "error"; error: unknown }; + +type ClawInstallSchemaVersionSnapshot = + | { kind: "ready"; schemaVersions: Map } + | { + kind: "state-error"; + error: unknown; + knownAgentIds: ReadonlySet; + ownershipUnknown: boolean; + } + | { kind: "uninitialized" }; + +// Install provenance is process-stable; only the state lifecycle and Claw mutations refresh it. +const snapshotsByPath = new Map(); +const snapshotListeners = new Set<() => void>(); + +function notifySnapshotListeners(): void { + for (const listener of snapshotListeners) { + listener(); + } +} + +function readSchemaVersions(db: DatabaseSync): ClawInstallSchemaVersionSnapshot { + try { + const hasInstallTable = db /* sqlite-allow-raw: lifecycle-owned state cache initialization. */ + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'claw_installs'") + .get(); + if (!hasInstallTable) { + return { kind: "ready", schemaVersions: new Map() }; + } + const rows = db /* sqlite-allow-raw: lifecycle-owned state cache initialization. */ + .prepare("SELECT agent_id, schema_version, agent_config_digest FROM claw_installs") + .all() as Array<{ + agent_id: string; + schema_version: string; + agent_config_digest: string; + }>; + const schemaVersions = new Map(); + for (const row of rows) { + try { + schemaVersions.set(row.agent_id, { + kind: "ok", + schemaVersion: parseClawInstallRecordSchemaVersion(row.schema_version), + agentConfigDigest: row.agent_config_digest, + }); + } catch (error) { + schemaVersions.set(row.agent_id, { kind: "error", error }); + } + } + return { + kind: "ready", + schemaVersions, + }; + } catch (error) { + return { + kind: "state-error", + error, + knownAgentIds: new Set(), + ownershipUnknown: true, + }; + } +} + +function knownAgentIds( + snapshot: ClawInstallSchemaVersionSnapshot | undefined, +): ReadonlySet { + if (snapshot?.kind === "ready") { + return new Set(snapshot.schemaVersions.keys()); + } + return snapshot?.kind === "state-error" ? snapshot.knownAgentIds : new Set(); +} + +function isOwnershipUnknown(snapshot: ClawInstallSchemaVersionSnapshot | undefined): boolean { + return ( + !snapshot || + snapshot.kind === "uninitialized" || + (snapshot.kind === "state-error" && snapshot.ownershipUnknown) + ); +} + +registerOpenClawStateDatabaseLifecycleListener((event) => { + const previous = snapshotsByPath.get(event.kind === "opened" ? event.database.path : event.path); + if (event.kind === "opened") { + const snapshot = readSchemaVersions(event.database.db); + snapshotsByPath.set( + event.database.path, + snapshot.kind === "state-error" + ? { + ...snapshot, + knownAgentIds: knownAgentIds(previous), + ownershipUnknown: isOwnershipUnknown(previous), + } + : snapshot, + ); + } else if (event.kind === "open-error") { + snapshotsByPath.set(event.path, { + kind: "state-error", + error: event.error, + knownAgentIds: knownAgentIds(previous), + ownershipUnknown: isOwnershipUnknown(previous), + }); + } else { + snapshotsByPath.set(event.path, { + kind: "state-error", + error: new Error("OpenClaw state database closed before consent provenance verification."), + knownAgentIds: knownAgentIds(previous), + ownershipUnknown: isOwnershipUnknown(previous), + }); + } + notifySnapshotListeners(); +}); + +function resolveSnapshotPath(options: OpenClawStateDatabaseOptions): string { + return options.database?.path ?? resolveDatabasePath(options); +} + +export function readCachedClawInstallSchemaVersions( + options: OpenClawStateDatabaseOptions = {}, +): ClawInstallSchemaVersionSnapshot { + return snapshotsByPath.get(resolveSnapshotPath(options)) ?? { kind: "uninitialized" }; +} + +export function initializeCachedClawInstallSchemaVersions( + options: OpenClawStateDatabaseOptions = {}, +): void { + const path = resolveSnapshotPath(options); + if (snapshotsByPath.has(path)) { + return; + } + try { + const snapshot = withExistingOpenClawStateDatabaseReadOnly(({ db, path: pathname }) => { + assertOpenClawStateDatabaseOwner(db, { pathname }); + return readSchemaVersions(db); + }, options); + snapshotsByPath.set(path, snapshot ?? { kind: "ready", schemaVersions: new Map() }); + } catch (error) { + snapshotsByPath.set(path, { + kind: "state-error", + error, + knownAgentIds: new Set(), + ownershipUnknown: true, + }); + } + notifySnapshotListeners(); +} + +export function registerClawInstallSchemaVersionSnapshotListener(listener: () => void): () => void { + snapshotListeners.add(listener); + return () => snapshotListeners.delete(listener); +} + +export function cacheClawInstallSchemaVersion( + agentId: string, + schemaVersion: ReturnType, + agentConfigDigest: string, + options: OpenClawStateDatabaseOptions = {}, +): void { + const snapshot = snapshotsByPath.get(resolveSnapshotPath(options)); + if (snapshot?.kind !== "ready") { + return; + } + snapshot.schemaVersions.set(agentId, { kind: "ok", schemaVersion, agentConfigDigest }); + notifySnapshotListeners(); +} + +export function deleteCachedClawInstallSchemaVersion( + agentId: string, + options: OpenClawStateDatabaseOptions = {}, +): void { + const snapshot = snapshotsByPath.get(resolveSnapshotPath(options)); + if (snapshot?.kind !== "ready" || !snapshot.schemaVersions.delete(agentId)) { + return; + } + notifySnapshotListeners(); +} diff --git a/src/claws/provenance-schema-version.test.ts b/src/claws/provenance-schema-version.test.ts new file mode 100644 index 000000000000..e26d162eade8 --- /dev/null +++ b/src/claws/provenance-schema-version.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { persistClawInstallRecord, readClawInstallRecord } from "./provenance.js"; +import { makeProvenancePlan, stateEnv } from "./provenance.test-helpers.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +async function makePlan() { + const root = tempDirs.make("openclaw-claw-provenance-schema-"); + return await makeProvenancePlan(root, { schemaVersion: 1, agent: { id: "worker" } }); +} + +function downgradeInstallRecord(root: string): void { + const env = stateEnv(root); + openOpenClawStateDatabase({ env }) + .db /* sqlite-allow-raw: test-only downgrade simulates pre-v2 provenance. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.v1", "worker"); +} + +describe("Claw install provenance schema migration", () => { + it("upgrades matching incomplete v1 provenance from an exact resume handoff", async () => { + const { root, plan } = await makePlan(); + const env = stateEnv(root); + persistClawInstallRecord(plan, { env, status: "pending", nowMs: 1 }); + downgradeInstallRecord(root); + const legacyRecord = readClawInstallRecord("worker", { env }); + if (!legacyRecord) { + throw new Error("expected legacy install record"); + } + + const resumed = persistClawInstallRecord(plan, { + env, + status: "pending", + nowMs: 2, + expectedExistingRecord: legacyRecord, + }); + + expect(resumed).toMatchObject({ + schemaVersion: "openclaw.clawInstallRecord.v2", + status: "pending", + addedAtMs: 1, + updatedAtMs: 1, + }); + }); + + it("atomically replaces legacy plan identity with the bounded resume plan", async () => { + const { root, plan: legacyPlan } = await makePlan(); + const env = stateEnv(root); + persistClawInstallRecord(legacyPlan, { env, status: "pending", nowMs: 1 }); + downgradeInstallRecord(root); + const legacyRecord = readClawInstallRecord("worker", { env }); + if (!legacyRecord) { + throw new Error("expected legacy install record"); + } + const boundedPlan = { + ...legacyPlan, + planIntegrity: "sha256:bounded-plan", + agent: { + ...legacyPlan.agent, + config: { + ...legacyPlan.agent.config, + tools: { profile: "full" as const, allow: ["read"] }, + }, + }, + }; + + const resumed = persistClawInstallRecord(boundedPlan, { + env, + status: "pending", + nowMs: 2, + expectedExistingRecord: legacyRecord, + expectedExistingPlan: legacyPlan, + }); + + expect(resumed).toMatchObject({ + schemaVersion: "openclaw.clawInstallRecord.v2", + planIntegrity: boundedPlan.planIntegrity, + status: "pending", + addedAtMs: 1, + updatedAtMs: 1, + }); + expect(resumed.agentConfigDigest).not.toBe(legacyRecord.agentConfigDigest); + expect(readClawInstallRecord("worker", { env })).toEqual(resumed); + }); + + it("can defer the legacy identity replacement until config migration succeeds", async () => { + const { root, plan: legacyPlan } = await makePlan(); + const env = stateEnv(root); + persistClawInstallRecord(legacyPlan, { env, status: "workspace_ready", nowMs: 1 }); + downgradeInstallRecord(root); + const legacyRecord = readClawInstallRecord("worker", { env }); + if (!legacyRecord) { + throw new Error("expected legacy install record"); + } + const boundedPlan = { + ...legacyPlan, + planIntegrity: "sha256:bounded-plan", + }; + + const deferred = persistClawInstallRecord(boundedPlan, { + env, + status: "pending", + expectedExistingRecord: legacyRecord, + expectedExistingPlan: legacyPlan, + deferLegacyPlanUpgrade: true, + }); + + expect(deferred).toEqual(legacyRecord); + expect(readClawInstallRecord("worker", { env })).toEqual(legacyRecord); + }); + + it("does not upgrade a v1 record outside an exact resume handoff", async () => { + const { root, plan } = await makePlan(); + const env = stateEnv(root); + persistClawInstallRecord(plan, { env, status: "partial", nowMs: 1 }); + downgradeInstallRecord(root); + + expect(() => persistClawInstallRecord(plan, { env, status: "pending", nowMs: 2 })).toThrow( + "not an exact resumable attempt", + ); + expect(readClawInstallRecord("worker", { env })).toMatchObject({ + schemaVersion: "openclaw.clawInstallRecord.v1", + status: "partial", + }); + }); +}); diff --git a/src/claws/provenance-schema-version.ts b/src/claws/provenance-schema-version.ts new file mode 100644 index 000000000000..3e24c56f3f0a --- /dev/null +++ b/src/claws/provenance-schema-version.ts @@ -0,0 +1,55 @@ +import type { DatabaseSync } from "node:sqlite"; +import { stableStringify } from "@openclaw/normalization-core"; + +const LEGACY_CLAW_INSTALL_RECORD_SCHEMA_VERSION = "openclaw.clawInstallRecord.v1" as const; +export const CLAW_INSTALL_RECORD_SCHEMA_VERSION = "openclaw.clawInstallRecord.v2" as const; +type ClawInstallRecordSchemaVersion = + | typeof LEGACY_CLAW_INSTALL_RECORD_SCHEMA_VERSION + | typeof CLAW_INSTALL_RECORD_SCHEMA_VERSION; + +export function parseClawInstallRecordSchemaVersion(value: string): ClawInstallRecordSchemaVersion { + if ( + value === LEGACY_CLAW_INSTALL_RECORD_SCHEMA_VERSION || + value === CLAW_INSTALL_RECORD_SCHEMA_VERSION + ) { + return value; + } + throw new Error(`Unsupported Claw install record schema ${JSON.stringify(value)}.`); +} + +export function upgradeClawInstallSchema< + TRecord extends { + schemaVersion: ClawInstallRecordSchemaVersion; + planIntegrity: string; + agentConfigDigest: string; + }, +>( + db: DatabaseSync, + agentId: string, + record: TRecord, + expectedRecord: TRecord | undefined, + replacement?: Pick, +): Omit & { schemaVersion: typeof CLAW_INSTALL_RECORD_SCHEMA_VERSION } { + if (!expectedRecord || stableStringify(record) !== stableStringify(expectedRecord)) { + throw new Error( + `Legacy Claw install record for agent ${JSON.stringify(agentId)} is not an exact resumable attempt.`, + ); + } + db /* sqlite-allow-raw: exact legacy retry atomically replaces the consent-bound plan identity. */ + .prepare( + `UPDATE claw_installs + SET schema_version = ?, plan_integrity = ?, agent_config_digest = ? + WHERE agent_id = ?`, + ) + .run( + CLAW_INSTALL_RECORD_SCHEMA_VERSION, + replacement?.planIntegrity ?? record.planIntegrity, + replacement?.agentConfigDigest ?? record.agentConfigDigest, + agentId, + ); + return { + ...record, + ...replacement, + schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + }; +} diff --git a/src/claws/provenance.test.ts b/src/claws/provenance.test.ts index 4b58624fcaa6..f9ee00158d34 100644 --- a/src/claws/provenance.test.ts +++ b/src/claws/provenance.test.ts @@ -108,7 +108,7 @@ describe("Claw root install provenance", () => { const record = persistClawInstallRecord(plan, { env: stateEnv(root), nowMs: 42 }); expect(record).toMatchObject({ - schemaVersion: "openclaw.clawInstallRecord.v1", + schemaVersion: "openclaw.clawInstallRecord.v2", claw: { name: "@acme/worker", version: "1.0.0", integrity: "sha256:manifest" }, manifestSchemaVersion: 1, planIntegrity: plan.planIntegrity, diff --git a/src/claws/provenance.ts b/src/claws/provenance.ts index 62a17f29bfba..f3fa16c914cc 100644 --- a/src/claws/provenance.ts +++ b/src/claws/provenance.ts @@ -1,5 +1,5 @@ // Persists the root ownership record for one Claw-created agent and workspace. -import { createHash } from "node:crypto"; + import type { DatabaseSync } from "node:sqlite"; import { stableStringify } from "@openclaw/normalization-core"; import { @@ -7,6 +7,7 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; +import { digestClawAgentConfig } from "./agent-config-digest.js"; import { CLAW_PACKAGE_REF_SCHEMA_VERSION, rowToPackageRef, @@ -21,14 +22,17 @@ import { selectClawBootstrapProvenanceColumns, } from "./provenance-bootstrap.js"; import { legacySafeColumnProjection } from "./provenance-legacy-columns.js"; +import { + cacheClawInstallSchemaVersion, + deleteCachedClawInstallSchemaVersion, +} from "./provenance-runtime-read.js"; +import * as installRecordSchema from "./provenance-schema-version.js"; import type { ClawAddPlan, ClawPackage, ResolvedClawPackage } from "./types.js"; export { CLAW_PACKAGE_REF_SCHEMA_VERSION, type PersistedClawPackageRef, } from "./package-extension-provenance.js"; -const CLAW_INSTALL_RECORD_SCHEMA_VERSION = "openclaw.clawInstallRecord.v1" as const; - export type ClawInstallStatus = | "pending" | "workspace_ready" @@ -36,31 +40,8 @@ export type ClawInstallStatus = | "complete" | "partial"; -type ClawInstallRow = { - agent_id: string; - schema_version: string; - source_kind: "package" | "development"; - claw_name: string; - claw_version: string; - package_root: string; - manifest_path: string; - integrity_kind: "artifact" | "development-snapshot"; - integrity: string; - source_byte_length: number | bigint; - manifest_schema_version: number | bigint; - plan_integrity: string; - workspace: string; - agent_config_digest: string; - agent_owned_paths_json: string; - bootstrap_source_path: string | null; - bootstrap_content_digest: string | null; - status: ClawInstallStatus; - added_at_ms: number | bigint; - updated_at_ms: number | bigint; -}; - export type PersistedClawInstall = { - schemaVersion: typeof CLAW_INSTALL_RECORD_SCHEMA_VERSION; + schemaVersion: ReturnType; claw: ClawAddPlan["claw"]; manifestSchemaVersion: ClawAddPlan["manifestSchemaVersion"]; planIntegrity: string; @@ -74,7 +55,7 @@ export type PersistedClawInstall = { updatedAtMs: number; }; -type InstallRow = { +type ClawInstallRow = { schema_version: string; source_kind: "package" | "development"; claw_name: string; @@ -97,9 +78,9 @@ type InstallRow = { updated_at_ms: number | bigint; }; -function rowToInstall(row: InstallRow): PersistedClawInstall { +function rowToRecord(row: ClawInstallRow): PersistedClawInstall { return { - schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + schemaVersion: installRecordSchema.parseClawInstallRecordSchemaVersion(row.schema_version), claw: { kind: row.source_kind, name: row.claw_name, @@ -125,10 +106,6 @@ function rowToInstall(row: InstallRow): PersistedClawInstall { }; } -function digestAgentConfig(plan: ClawAddPlan): string { - return `sha256:${createHash("sha256").update(stableStringify(plan.agent.config)).digest("hex")}`; -} - function agentOwnedPaths(plan: ClawAddPlan): string[] { return plan.actions.filter((action) => action.kind === "agent").map((action) => action.target); } @@ -141,41 +118,12 @@ function bootstrapProvenance(plan: ClawAddPlan) { : undefined; } -function rowToRecord(row: ClawInstallRow): PersistedClawInstall { - return { - schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, - claw: { - kind: row.source_kind, - name: row.claw_name, - version: row.claw_version, - packageRoot: row.package_root, - manifestPath: row.manifest_path, - integrityKind: row.integrity_kind, - integrity: row.integrity, - byteLength: Number(row.source_byte_length), - }, - manifestSchemaVersion: Number( - row.manifest_schema_version, - ) as ClawAddPlan["manifestSchemaVersion"], - planIntegrity: row.plan_integrity, - agentId: row.agent_id, - workspace: row.workspace, - agentConfigDigest: row.agent_config_digest, - agentOwnedPaths: JSON.parse(row.agent_owned_paths_json) as string[], - ...clawBootstrapProvenanceFromRow(row), - status: row.status, - addedAtMs: Number(row.added_at_ms), - updatedAtMs: Number(row.updated_at_ms), - }; -} - export function clawInstallRecordMatchesPlan( record: PersistedClawInstall, plan: ClawAddPlan, ): boolean { const bootstrap = bootstrapProvenance(plan); return ( - record.schemaVersion === CLAW_INSTALL_RECORD_SCHEMA_VERSION && record.claw.kind === plan.claw.kind && record.claw.name === plan.claw.name && record.claw.version === plan.claw.version && @@ -187,7 +135,7 @@ export function clawInstallRecordMatchesPlan( record.manifestSchemaVersion === plan.manifestSchemaVersion && record.planIntegrity === plan.planIntegrity && record.workspace === plan.agent.workspace && - record.agentConfigDigest === digestAgentConfig(plan) && + record.agentConfigDigest === digestClawAgentConfig(plan.agent.config) && stableStringify(record.agentOwnedPaths) === stableStringify(agentOwnedPaths(plan)) && record.bootstrap?.sourcePath === bootstrap?.sourcePath && record.bootstrap?.contentDigest === bootstrap?.contentDigest @@ -217,39 +165,51 @@ export function readClawInstallRecordFromDatabase( return row ? rowToRecord(row) : undefined; } -function getClawInstallRow( - agentId: string, - options: OpenClawStateDatabaseOptions, -): ClawInstallRow | undefined { - return selectClawInstallRow(openOpenClawStateDatabase(options).db, agentId); -} - export function readClawInstallRecord( agentId: string, options: OpenClawStateDatabaseOptions = {}, ): PersistedClawInstall | undefined { - const row = getClawInstallRow(agentId, options); + const row = selectClawInstallRow(openOpenClawStateDatabase(options).db, agentId); return row ? rowToRecord(row) : undefined; } -function isSameInstallAttempt(row: ClawInstallRow, plan: ClawAddPlan): boolean { - return clawInstallRecordMatchesPlan(rowToRecord(row), plan); -} - export function persistClawInstallRecord( plan: ClawAddPlan, - options: OpenClawStateDatabaseOptions & { status?: ClawInstallStatus; nowMs?: number } = {}, + options: OpenClawStateDatabaseOptions & { + status?: ClawInstallStatus; + nowMs?: number; + expectedExistingRecord?: PersistedClawInstall; + expectedExistingPlan?: ClawAddPlan; + deferLegacyPlanUpgrade?: boolean; + } = {}, ): PersistedClawInstall { const nowMs = options.nowMs ?? Date.now(); const status = options.status ?? "complete"; - const agentConfigDigest = digestAgentConfig(plan); + const agentConfigDigest = digestClawAgentConfig(plan.agent.config); const ownedPaths = agentOwnedPaths(plan); const bootstrap = bootstrapProvenance(plan); - return runOpenClawStateWriteTransaction(({ db }) => { + const persistedRecord = runOpenClawStateWriteTransaction(({ db }) => { const existing = selectClawInstallRow(db, plan.agent.finalId); if (existing) { - if (existing.status !== "complete" && isSameInstallAttempt(existing, plan)) { - return rowToRecord(existing); + const record = rowToRecord(existing); + const expectedPlan = options.expectedExistingPlan ?? plan; + if (existing.status !== "complete" && clawInstallRecordMatchesPlan(record, expectedPlan)) { + if (record.schemaVersion !== installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION) { + if (options.deferLegacyPlanUpgrade) { + return record; + } + return installRecordSchema.upgradeClawInstallSchema( + db, + plan.agent.finalId, + record, + options.expectedExistingRecord, + { + planIntegrity: plan.planIntegrity, + agentConfigDigest, + }, + ); + } + return record; } // A nonmatching partial attempt remains durable ownership evidence. A later // remove/doctor lifecycle must clear it; a new plan must never overwrite it. @@ -274,7 +234,7 @@ export function persistClawInstallRecord( )`, ).run({ agent_id: plan.agent.finalId, - schema_version: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + schema_version: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION, source_kind: plan.claw.kind, claw_name: plan.claw.name, claw_version: plan.claw.version, @@ -295,7 +255,7 @@ export function persistClawInstallRecord( updated_at_ms: nowMs, }); return { - schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + schemaVersion: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION, claw: plan.claw, manifestSchemaVersion: plan.manifestSchemaVersion, planIntegrity: plan.planIntegrity, @@ -309,6 +269,13 @@ export function persistClawInstallRecord( updatedAtMs: nowMs, }; }, options); + cacheClawInstallSchemaVersion( + plan.agent.finalId, + persistedRecord.schemaVersion, + persistedRecord.agentConfigDigest, + options, + ); + return persistedRecord; } export function updateClawInstallRecordStatus( @@ -361,6 +328,7 @@ export function deleteClawInstallRecord( ); } }, options); + deleteCachedClawInstallSchemaVersion(agentId, options); } export function readClawInstallRecords( @@ -380,8 +348,8 @@ export function readClawInstallRecords( FROM claw_installs ORDER BY agent_id`, ) - .all() as InstallRow[]; - return rows.map(rowToInstall); + .all() as ClawInstallRow[]; + return rows.map(rowToRecord); } export function updateClawInstallRecord( @@ -400,7 +368,7 @@ export function updateClawInstallRecord( } const updatedAtMs = options.nowMs ?? Date.now(); const status = options.status ?? "complete"; - const agentConfigDigest = digestAgentConfig(plan); + const agentConfigDigest = digestClawAgentConfig(plan.agent.config); const ownedAgentPaths = plan.actions .filter((action) => action.kind === "agent") .map((action) => action.target); @@ -409,7 +377,8 @@ export function updateClawInstallRecord( const result = db /* sqlite-allow-raw: Claw install provenance compare-and-swap write. */ .prepare( `UPDATE claw_installs - SET source_kind = @source_kind, + SET schema_version = @schema_version, + source_kind = @source_kind, claw_name = @claw_name, claw_version = @claw_version, package_root = @package_root, @@ -432,6 +401,7 @@ export function updateClawInstallRecord( ) .run({ agent_id: plan.agent.finalId, + schema_version: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION, source_kind: plan.claw.kind, claw_name: plan.claw.name, claw_version: plan.claw.version, @@ -458,8 +428,8 @@ export function updateClawInstallRecord( ); } }, options); - return { - schemaVersion: CLAW_INSTALL_RECORD_SCHEMA_VERSION, + const record = { + schemaVersion: installRecordSchema.CLAW_INSTALL_RECORD_SCHEMA_VERSION, claw: plan.claw, manifestSchemaVersion: plan.manifestSchemaVersion, planIntegrity: plan.planIntegrity, @@ -472,6 +442,13 @@ export function updateClawInstallRecord( addedAtMs: current.addedAtMs, updatedAtMs, }; + cacheClawInstallSchemaVersion( + plan.agent.finalId, + record.schemaVersion, + record.agentConfigDigest, + options, + ); + return record; } export function persistClawPackageRef( diff --git a/src/claws/reader.ts b/src/claws/reader.ts index 7f25463b371d..8822436bc066 100644 --- a/src/claws/reader.ts +++ b/src/claws/reader.ts @@ -597,7 +597,19 @@ async function resolveSource( }; } -export async function readClawManifestFile(path: string): Promise { +export async function readClawManifestFile( + path: string, + options: { + allowLegacyDynamicToolProfile?: boolean; + authorizeLegacyDynamicToolProfile?: (params: { + manifest: ClawManifest; + source: Pick< + ClawSourceIdentity, + "kind" | "name" | "version" | "packageRoot" | "manifestPath" + >; + }) => boolean | Promise; + } = {}, +): Promise { const sourceResult = await resolveSource(path); if (!sourceResult.ok) { return sourceResult; @@ -628,9 +640,24 @@ export async function readClawManifestFile(path: string): Promise { + if (tools.profile === "full" && !tools.allow) { + ctx.addIssue({ + code: "custom", + path: ["profile"], + message: "The full tool profile requires a bounded explicit allowlist.", + }); + } + if (tools.profile && tools.profile !== "full" && tools.allow) { + const profileAllow = expandToolGroups(resolveToolProfilePolicy(tools.profile)?.allow); + if ( + tools.allow.some( + (grant) => + !profileAllow.some((tool) => + isToolAllowedByPolicyName(tool, { allow: [grant] }), + ) && + !(profileAllow.includes("bundle-mcp") && isConcreteBundleMcpToolName(grant)), + ) + ) { + ctx.addIssue({ + code: "custom", + path: ["allow"], + message: "Every agent tools allow grant must overlap the selected profile.", + }); + } + } + if ( + tools.profile && + resolveClawToolProfileSnapshot(tools)?.allow.includes("bundle-mcp") + ) { + ctx.addIssue({ + code: "custom", + path: ["allow"], + message: + "Profiles containing bundle-mcp require a bounded allowlist of concrete tool names.", + }); + } + if (tools.alsoAllow && !tools.profile) { + ctx.addIssue({ + code: "custom", + path: ["alsoAllow"], + message: "Agent tools can set alsoAllow only when a bounded profile is selected.", + }); + } if (tools.allow && tools.alsoAllow) { ctx.addIssue({ code: "custom", @@ -444,6 +514,7 @@ const manifestSchema = z .strict() .superRefine((manifest, ctx) => { const workspaceTargets = new Set(); + const nativeBootstrapTarget = new Set([portableClawPathKey("BOOTSTRAP.md")]); for (const name of CLAW_BOOTSTRAP_FILE_NAMES) { if (manifest.workspace.bootstrapFiles[name]) { workspaceTargets.add(portableClawPathKey(name)); @@ -451,7 +522,7 @@ const manifestSchema = z } manifest.workspace.files.forEach((file, index) => { const destinationKey = portableClawPathKey(file.path); - if (destinationKey === portableClawPathKey("BOOTSTRAP.md")) { + if (conflictsWithClawPath(nativeBootstrapTarget, destinationKey)) { ctx.addIssue({ code: "custom", path: ["workspace", "files", index, "path"], diff --git a/src/claws/tool-policy-runtime.integration.test.ts b/src/claws/tool-policy-runtime.integration.test.ts new file mode 100644 index 000000000000..7630568e44b4 --- /dev/null +++ b/src/claws/tool-policy-runtime.integration.test.ts @@ -0,0 +1,392 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { resolveConversationCapabilityProfile } from "../agents/conversation-capability-profile.js"; +import { + buildConversationToolPolicyPipelineSteps, + resolveConversationToolPolicies, +} from "../agents/conversation-tool-policy-pipeline.js"; +import { applyToolPolicyPipeline } from "../agents/tool-policy-pipeline.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../config/runtime-snapshot.js"; +import { + closeOpenClawStateDatabase, + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { persistClawInstallRecord } from "./provenance.js"; +import { makeProvenancePlan, stateEnv } from "./provenance.test-helpers.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + clearRuntimeConfigSnapshot(); + vi.unstubAllEnvs(); +}); + +describe("Claw tool policy consent provenance", () => { + it("does not create writable state for an ordinary named profile", () => { + const root = tempDirs.make("openclaw-non-claw-tool-consent-"); + vi.stubEnv("OPENCLAW_STATE_DIR", join(root, "state")); + const config = { agents: { list: [{ id: "worker", tools: { profile: "coding" as const } }] } }; + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).not.toThrow(); + expect(existsSync(join(root, "state"))).toBe(false); + }); + + it("does not infer Claw ownership before consent provenance is initialized", () => { + const root = tempDirs.make("openclaw-uninitialized-claw-tool-consent-"); + const stateDir = join(root, "state"); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const config = { + agents: { + list: [{ id: "worker", tools: { profile: "full" as const, allow: ["read"] } }], + }, + }; + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).not.toThrow(); + expect(existsSync(stateDir)).toBe(false); + }); + + it("fails an ordinary named profile closed when initial ownership is unreadable", () => { + const root = tempDirs.make("openclaw-unreadable-non-claw-tool-consent-"); + const stateDir = join(root, "state"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = resolveOpenClawStateSqlitePath(env); + mkdirSync(dirname(databasePath), { recursive: true }); + writeFileSync(databasePath, "not a sqlite database"); + const before = readFileSync(databasePath); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + + const config = { + agents: { + list: [{ id: "worker", tools: { profile: "coding" as const } }], + }, + }; + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).toThrow("Cannot verify the installed tool authority"); + expect(readFileSync(databasePath)).toEqual(before); + }); + + it("fails a known Claw closed without mutating unreadable consent provenance", async () => { + const root = tempDirs.make("openclaw-unreadable-claw-tool-consent-"); + const stateDir = join(root, "state"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const databasePath = resolveOpenClawStateSqlitePath(env); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + const { plan } = await makeProvenancePlan( + root, + { schemaVersion: 1, agent: { id: "worker" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(plan, { env }); + closeOpenClawStateDatabase(); + writeFileSync(databasePath, "not a sqlite database"); + const before = readFileSync(databasePath); + + const config = { agents: { list: [plan.agent.config] } }; + expect(() => openOpenClawStateDatabase({ env })).toThrow(); + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).toThrow("Cannot verify the installed tool authority"); + expect(readFileSync(databasePath)).toEqual(before); + }); + + it("fails closed after the prepared state database closes", async () => { + const root = tempDirs.make("openclaw-closed-claw-tool-consent-"); + const env = stateEnv(root); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + const { plan } = await makeProvenancePlan( + root, + { schemaVersion: 1, agent: { id: "worker" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(plan, { env }); + const config = { agents: { list: [plan.agent.config] } }; + setRuntimeConfigSnapshot(config); + closeOpenClawStateDatabase(); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).toThrow("Cannot verify the installed tool authority"); + }); + + it("fails closed when the active agent config does not match consent provenance", async () => { + const root = tempDirs.make("openclaw-modified-claw-tool-consent-"); + const env = stateEnv(root); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + const { plan } = await makeProvenancePlan( + root, + { schemaVersion: 1, agent: { id: "worker" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(plan, { env }); + const config = { + agents: { + list: [ + { + ...plan.agent.config, + tools: { profile: "full" as const, allow: ["read", "exec"] }, + }, + ], + }, + }; + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).toThrow("Cannot verify the installed tool authority"); + }); + + it("fails closed after a host upgrade leaves legacy profile provenance", async () => { + const root = tempDirs.make("openclaw-claw-tool-consent-"); + const env = stateEnv(root); + vi.stubEnv("OPENCLAW_STATE_DIR", join(root, "state")); + const { plan } = await makeProvenancePlan( + root, + { schemaVersion: 1, agent: { id: "worker" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "coding", allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(plan, { env }); + + const config = { agents: { list: [plan.agent.config] } }; + setRuntimeConfigSnapshot(config); + const capabilityProfile = resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }); + const policies = resolveConversationToolPolicies({ capabilityProfile }); + const filtered = applyToolPolicyPipeline({ + tools: [{ name: "read" }, { name: "future_tool" }], + toolMeta: (tool) => (tool.name === "future_tool" ? { pluginId: "read" } : undefined), + warn: () => {}, + steps: buildConversationToolPolicyPipelineSteps({ + capabilityProfile, + policies, + includeRuntimeToolPolicy: true, + }), + }); + expect(filtered.map((tool) => tool.name)).toEqual(["read"]); + + openOpenClawStateDatabase({ env }) + .db /* sqlite-allow-raw: test-only downgrade simulates an install created by the previous host. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.v1", "worker"); + closeOpenClawStateDatabase(); + openOpenClawStateDatabase({ env }); + + const legacyConfig = { + agents: { + list: [ + { + ...plan.agent.config, + tools: { profile: "coding" as const }, + }, + ], + }, + }; + setRuntimeConfigSnapshot(legacyConfig); + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config: legacyConfig, + }), + ).toThrow("uses a legacy dynamic tool policy"); + }); + + it("gives a legacy unbounded full profile an actionable repair path", async () => { + const root = tempDirs.make("openclaw-claw-full-tool-consent-"); + const env = stateEnv(root); + vi.stubEnv("OPENCLAW_STATE_DIR", join(root, "state")); + const { plan } = await makeProvenancePlan( + root, + { schemaVersion: 1, agent: { id: "worker" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(plan, { env }); + openOpenClawStateDatabase({ env }) + .db /* sqlite-allow-raw: test-only downgrade simulates a legacy unbounded full profile. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.v1", "worker"); + closeOpenClawStateDatabase(); + openOpenClawStateDatabase({ env }); + + const config = { + agents: { + list: [ + { + ...plan.agent.config, + tools: { profile: "full" as const }, + }, + ], + }, + }; + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }), + ).toThrow( + "Add an explicit tools.allow list to its package OpenClaw profile, then run `openclaw claws update worker`", + ); + }); + + it("isolates an unsupported install record from other agents", async () => { + const root = tempDirs.make("openclaw-claw-tool-consent-isolation-"); + const env = stateEnv(root); + const validRoot = join(root, "valid"); + const invalidRoot = join(root, "invalid"); + mkdirSync(validRoot); + mkdirSync(invalidRoot); + vi.stubEnv("OPENCLAW_STATE_DIR", env.OPENCLAW_STATE_DIR); + const { plan: validPlan } = await makeProvenancePlan( + validRoot, + { schemaVersion: 1, agent: { id: "valid" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read"] } }, + }, + }, + ); + const { plan: invalidPlan } = await makeProvenancePlan( + invalidRoot, + { schemaVersion: 1, agent: { id: "invalid" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { profile: "full", allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(validPlan, { env }); + persistClawInstallRecord(invalidPlan, { env }); + openOpenClawStateDatabase({ env }) + .db /* sqlite-allow-raw: test-only corruption verifies per-agent failure isolation. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.unsupported", "invalid"); + closeOpenClawStateDatabase(); + openOpenClawStateDatabase({ env }); + + const config = { agents: { list: [validPlan.agent.config, invalidPlan.agent.config] } }; + setRuntimeConfigSnapshot(config); + + expect(() => + resolveConversationCapabilityProfile({ + agentId: "valid", + config, + }), + ).not.toThrow(); + expect(() => + resolveConversationCapabilityProfile({ + agentId: "invalid", + config, + }), + ).toThrow("Cannot verify the installed tool authority"); + }); + + it("does not intersect a standalone Claw allowlist with the host profile", async () => { + const root = tempDirs.make("openclaw-claw-standalone-tool-consent-"); + const env = stateEnv(root); + vi.stubEnv("OPENCLAW_STATE_DIR", join(root, "state")); + const { plan } = await makeProvenancePlan( + root, + { schemaVersion: 1, agent: { id: "worker" } }, + { + openClawProfile: { + schemaVersion: 1, + agent: { tools: { allow: ["read"] } }, + }, + }, + ); + persistClawInstallRecord(plan, { env }); + + const config = { + tools: { profile: "minimal" as const }, + agents: { list: [plan.agent.config] }, + }; + setRuntimeConfigSnapshot(config); + const capabilityProfile = resolveConversationCapabilityProfile({ + agentId: "worker", + config, + }); + const policies = resolveConversationToolPolicies({ + capabilityProfile, + additionalPolicyAllow: ["message", "tool_search"], + }); + const filtered = applyToolPolicyPipeline({ + tools: [{ name: "read" }, { name: "exec" }, { name: "message" }, { name: "tool_search" }], + toolMeta: () => undefined, + warn: () => {}, + steps: buildConversationToolPolicyPipelineSteps({ + capabilityProfile, + policies, + includeRuntimeToolPolicy: true, + }), + }); + + expect(plan.agent.config.tools).toEqual({ profile: "full", allow: ["read"] }); + expect(filtered.map((tool) => tool.name)).toEqual(["read"]); + }); +}); diff --git a/src/claws/tool-policy-runtime.test.ts b/src/claws/tool-policy-runtime.test.ts new file mode 100644 index 000000000000..834c9a8fabaf --- /dev/null +++ b/src/claws/tool-policy-runtime.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resolveClawToolPolicyConsent } from "./tool-policy-runtime.js"; + +describe("resolveClawToolPolicyConsent", () => { + it("leaves ordinary non-Claw profiles dynamic", () => { + const tools = { profile: "coding" }; + expect( + resolveClawToolPolicyConsent({ + agentTools: tools, + agentId: "worker", + profile: "coding", + ownsProfile: true, + hasAgentAllowlist: false, + }), + ).toEqual({ frozen: false }); + }); + + it("does not treat an inherited global profile as Claw-owned authority", () => { + expect( + resolveClawToolPolicyConsent({ + agentId: "worker", + profile: "coding", + ownsProfile: false, + hasAgentAllowlist: false, + }), + ).toEqual({ frozen: false }); + }); +}); diff --git a/src/claws/tool-policy-runtime.ts b/src/claws/tool-policy-runtime.ts new file mode 100644 index 000000000000..cc7ae0f03e1c --- /dev/null +++ b/src/claws/tool-policy-runtime.ts @@ -0,0 +1,168 @@ +import { listAgentEntries } from "../agents/agent-scope.js"; +import { registerRuntimeConfigSnapshotPreparer } from "../config/runtime-snapshot.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; +import { digestClawAgentConfig } from "./agent-config-digest.js"; +import { + initializeCachedClawInstallSchemaVersions, + readCachedClawInstallSchemaVersions, + registerClawInstallSchemaVersionSnapshotListener, +} from "./provenance-runtime-read.js"; +import { CLAW_INSTALL_RECORD_SCHEMA_VERSION } from "./provenance-schema-version.js"; + +const frozenToolAllowPolicies = new WeakSet(); +type PreparedClawToolPolicy = + | { kind: "current" } + | { kind: "legacy" } + | { kind: "state-error"; error: unknown }; +const preparedClawToolPolicies = new WeakMap(); +type ClawToolPolicyCandidate = { agentId: string; agentConfigDigest: string; tools: object }; +let preparedCandidates: ClawToolPolicyCandidate[] = []; +let preparedStateOptions: OpenClawStateDatabaseOptions = {}; +let readPreparedSchemaVersions = readCachedClawInstallSchemaVersions; +const uninitializedStateError = new Error( + "OpenClaw state database has not initialized Claw consent provenance.", +); + +export function markFrozenClawToolAllowPolicy(policy: object | undefined): void { + if (policy) { + frozenToolAllowPolicies.add(policy); + } +} + +export function isFrozenClawToolAllowPolicy(policy: object | undefined): boolean { + return policy ? frozenToolAllowPolicies.has(policy) : false; +} + +function applyPreparedClawToolPolicyConsent(): void { + const snapshot = readPreparedSchemaVersions(preparedStateOptions); + for (const candidate of preparedCandidates) { + if (snapshot.kind === "uninitialized") { + preparedClawToolPolicies.set(candidate.tools, { + kind: "state-error", + error: uninitializedStateError, + }); + continue; + } + if (snapshot.kind === "state-error") { + if (snapshot.ownershipUnknown || snapshot.knownAgentIds.has(candidate.agentId)) { + preparedClawToolPolicies.set(candidate.tools, { + kind: "state-error", + error: snapshot.error, + }); + } else { + preparedClawToolPolicies.delete(candidate.tools); + } + continue; + } + const schemaVersionRead = snapshot.schemaVersions.get(candidate.agentId); + if (!schemaVersionRead) { + preparedClawToolPolicies.delete(candidate.tools); + continue; + } + if (schemaVersionRead.kind === "error") { + preparedClawToolPolicies.set(candidate.tools, { + kind: "state-error", + error: schemaVersionRead.error, + }); + continue; + } + if ( + schemaVersionRead.schemaVersion === CLAW_INSTALL_RECORD_SCHEMA_VERSION && + schemaVersionRead.agentConfigDigest !== candidate.agentConfigDigest + ) { + preparedClawToolPolicies.set(candidate.tools, { + kind: "state-error", + error: new Error("Claw agent configuration does not match its consent provenance."), + }); + continue; + } + preparedClawToolPolicies.set(candidate.tools, { + kind: + schemaVersionRead.schemaVersion === CLAW_INSTALL_RECORD_SCHEMA_VERSION + ? "current" + : "legacy", + }); + } +} + +function prepareClawToolPolicyConsent( + config: OpenClawConfig, + options: OpenClawStateDatabaseOptions & { + readSchemaVersions?: typeof readCachedClawInstallSchemaVersions; + } = {}, +): void { + for (const candidate of preparedCandidates) { + preparedClawToolPolicies.delete(candidate.tools); + } + preparedCandidates = listAgentEntries(config).flatMap((agent) => { + const tools = agent.tools; + return tools && (tools.profile || tools.allow?.length) + ? [{ agentId: agent.id, agentConfigDigest: digestClawAgentConfig(agent), tools }] + : []; + }); + const { readSchemaVersions, ...stateOptions } = options; + preparedStateOptions = stateOptions; + readPreparedSchemaVersions = readSchemaVersions ?? readCachedClawInstallSchemaVersions; + if (!readSchemaVersions) { + initializeCachedClawInstallSchemaVersions(stateOptions); + } + applyPreparedClawToolPolicyConsent(); +} + +registerClawInstallSchemaVersionSnapshotListener(() => applyPreparedClawToolPolicyConsent()); +registerRuntimeConfigSnapshotPreparer((config) => prepareClawToolPolicyConsent(config)); + +class ClawToolProfileConsentError extends Error { + constructor(agentId: string, options: { unboundedFullProfile?: boolean } = {}) { + super( + options.unboundedFullProfile + ? `Claw-managed agent ${JSON.stringify(agentId)} uses the legacy unbounded full tool profile. ` + + "Add an explicit tools.allow list to its package OpenClaw profile, then " + + `run \`openclaw claws update ${agentId}\` and approve the refreshed tool authority.` + : `Claw-managed agent ${JSON.stringify(agentId)} uses a legacy dynamic tool policy. ` + + `Run \`openclaw claws update ${agentId}\` and approve the refreshed tool authority before running it.`, + ); + this.name = "ClawToolProfileConsentError"; + } +} + +class ClawToolProfileConsentStateError extends Error { + constructor(agentId: string, cause: unknown) { + super( + `Cannot verify the installed tool authority for Claw-managed agent ${JSON.stringify(agentId)}. ` + + "Repair the OpenClaw state database before running it.", + { cause }, + ); + this.name = "ClawToolProfileConsentStateError"; + } +} + +export function resolveClawToolPolicyConsent(params: { + agentTools?: object; + agentId?: string; + hasAgentAllowlist: boolean; + ownsProfile: boolean; + profile?: string; +}): { frozen: boolean } { + if (!params.agentId || (!params.ownsProfile && !params.hasAgentAllowlist)) { + return { frozen: false }; + } + const prepared = params.agentTools ? preparedClawToolPolicies.get(params.agentTools) : undefined; + if (!prepared) { + return { frozen: false }; + } + if (prepared.kind === "state-error") { + throw new ClawToolProfileConsentStateError(params.agentId, prepared.error); + } + if ( + prepared.kind === "legacy" || + (params.ownsProfile && (params.profile !== "full" || !params.hasAgentAllowlist)) + ) { + throw new ClawToolProfileConsentError(params.agentId, { + unboundedFullProfile: + prepared.kind === "legacy" && params.profile === "full" && !params.hasAgentAllowlist, + }); + } + return { frozen: params.hasAgentAllowlist }; +} diff --git a/src/claws/tool-profile-consent.test.ts b/src/claws/tool-profile-consent.test.ts new file mode 100644 index 000000000000..16ba4345f506 --- /dev/null +++ b/src/claws/tool-profile-consent.test.ts @@ -0,0 +1,152 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { resolveToolProfilePolicy } from "../agents/tool-policy-shared.js"; +import { buildClawAddPlan } from "./lifecycle.js"; +import { parseClawManifest } from "./schema.js"; +import { materializeClawToolProfile } from "./tool-profile-consent.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("Claw tool profile consent", () => { + it("materializes a built-in profile into the consented agent config", async () => { + const minimal = resolveToolProfilePolicy("minimal"); + if (!minimal?.allow) { + throw new Error("expected minimal profile allowlist"); + } + const packageRoot = tempDirs.make("openclaw-claw-tool-profile-"); + await mkdir(packageRoot, { recursive: true }); + const parsed = parseClawManifest({ + schemaVersion: 1, + agent: { id: "profile-worker" }, + }); + if (!parsed.ok) { + throw new Error(JSON.stringify(parsed.diagnostics)); + } + + const plan = await buildClawAddPlan({ + manifest: parsed.manifest, + openClawProfile: { + schemaVersion: 1, + agent: { + tools: { + profile: "minimal", + alsoAllow: ["tts"], + deny: ["exec"], + fs: { workspaceOnly: true }, + }, + }, + }, + source: { + kind: "package", + name: "@acme/profile-worker", + version: "1.0.0", + packageRoot, + manifestPath: join(packageRoot, "openclaw.claw.json"), + integrityKind: "development-snapshot", + integrity: "sha256:test", + byteLength: 0, + }, + context: { workspace: join(packageRoot, "workspace") }, + }); + + expect(plan.agent.config.tools).toEqual({ + profile: "full", + allow: [...minimal.allow, "tts"], + deny: ["exec"], + fs: { workspaceOnly: true }, + }); + expect(plan.capabilityChanges).toContainEqual( + expect.objectContaining({ + path: "agent", + effect: expect.objectContaining({ + tools: expect.objectContaining({ profile: "minimal", alsoAllow: ["tts"] }), + }), + }), + ); + }); + + it("preserves an explicit allowlist as a frozen profile intersection", async () => { + const settings = materializeClawToolProfile({ + tools: { + profile: "coding", + allow: ["read", "write", "github__list_issues"], + }, + }); + + expect(settings.tools).toEqual({ + profile: "full", + allow: ["read", "write", "apply_patch", "github__list_issues"], + }); + }); + + it("uses a bounded full profile to override inherited global profiles", () => { + expect( + materializeClawToolProfile({ + tools: { + profile: "full", + allow: ["read", "write"], + }, + }).tools, + ).toEqual({ + profile: "full", + allow: ["read", "write"], + }); + }); + + it("freezes a standalone allowlist against inherited host profiles", () => { + expect( + materializeClawToolProfile({ + tools: { + allow: ["read", "write", "cron"], + deny: ["exec"], + }, + }).tools, + ).toEqual({ + profile: "full", + allow: ["read", "write", "automations"], + deny: ["exec"], + }); + }); + + it("freezes the bounded portion of a legacy dynamic profile for update", () => { + const settings = materializeClawToolProfile( + { + tools: { + profile: "coding", + deny: ["exec"], + }, + }, + { allowLegacyDynamicProfile: true }, + ); + + expect(settings.tools).toMatchObject({ + profile: "full", + allow: expect.arrayContaining(["read", "write", "apply_patch"]), + deny: ["exec"], + }); + expect(settings.tools?.allow).not.toContain("bundle-mcp"); + }); + + it("fails closed for an empty explicit profile intersection", () => { + expect(() => + materializeClawToolProfile({ + tools: { + profile: "coding", + allow: ["tts"], + }, + }), + ).toThrow("does not overlap"); + }); + + it("rejects an unresolved Bundle MCP selector in a frozen profile", () => { + expect(() => + materializeClawToolProfile({ + tools: { + profile: "coding", + }, + }), + ).toThrow("bundle-mcp"); + }); +}); diff --git a/src/claws/tool-profile-consent.ts b/src/claws/tool-profile-consent.ts new file mode 100644 index 000000000000..6613d8be14ed --- /dev/null +++ b/src/claws/tool-profile-consent.ts @@ -0,0 +1,98 @@ +import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js"; +import { expandToolGroups, resolveToolProfilePolicy } from "../agents/tool-policy-shared.js"; +import type { ClawOpenClawProfile } from "./types.js"; + +type ClawToolSettings = NonNullable; +type ClawToolProfileSelection = Omit< + Pick, + "profile" +> & { profile?: string }; + +export function isConcreteBundleMcpToolName(name: string): boolean { + return name.length <= 64 && /^[A-Za-z][A-Za-z0-9_-]*__[A-Za-z][A-Za-z0-9_-]*$/u.test(name); +} + +export function resolveClawToolProfileSnapshot( + tools: ClawToolProfileSelection, +): { allow: string[]; deny: string[] } | undefined { + if (!tools.profile) { + return undefined; + } + const profile = resolveToolProfilePolicy(tools.profile); + if (!profile) { + return undefined; + } + const profileAllow = expandToolGroups(profile.allow); + const explicitAllow = tools.allow + ? profileAllow.includes("*") + ? expandToolGroups(tools.allow) + : Array.from( + new Set([ + ...profileAllow.filter((tool) => + isToolAllowedByPolicyName(tool, { allow: tools.allow }), + ), + ...(profileAllow.includes("bundle-mcp") + ? tools.allow.filter(isConcreteBundleMcpToolName) + : []), + ]), + ) + : undefined; + return { + allow: + explicitAllow ?? expandToolGroups([...(profile.allow ?? []), ...(tools.alsoAllow ?? [])]), + deny: expandToolGroups([...(profile.deny ?? []), ...(tools.deny ?? [])]), + }; +} + +export function materializeClawToolProfile( + settings: ClawOpenClawProfile["agent"], + options: { allowLegacyDynamicProfile?: boolean } = {}, +): ClawOpenClawProfile["agent"] { + const tools = settings.tools; + if (!tools) { + return settings; + } + if (!tools.profile) { + const allow = expandToolGroups(tools.allow); + const deny = expandToolGroups(tools.deny); + return { + ...settings, + tools: { + ...(allow.length > 0 ? { profile: "full" as const, allow } : {}), + ...(tools.alsoAllow ? { alsoAllow: expandToolGroups(tools.alsoAllow) } : {}), + ...(deny.length > 0 ? { deny } : {}), + ...(tools.fs ? { fs: tools.fs } : {}), + }, + }; + } + const snapshot = resolveClawToolProfileSnapshot(tools); + if (!snapshot) { + return settings; + } + if (tools.profile === "full" && !tools.allow) { + throw new Error("Claw full tool profile requires a bounded explicit allowlist."); + } + if (tools.allow && snapshot.allow.length === 0) { + throw new Error("Claw tool allowlist does not overlap the selected profile."); + } + const allow = options.allowLegacyDynamicProfile + ? snapshot.allow.filter((grant) => grant !== "bundle-mcp") + : snapshot.allow; + if (allow.includes("bundle-mcp")) { + throw new Error( + "Claw tool profiles containing bundle-mcp require an explicit bounded allowlist of concrete tool names.", + ); + } + if (allow.length === 0) { + throw new Error("Legacy Claw tool profile has no bounded authority to preserve."); + } + return { + ...settings, + tools: { + profile: "full", + allow, + ...(snapshot.deny.length > 0 ? { deny: snapshot.deny } : {}), + ...(tools.fs ? { fs: tools.fs } : {}), + }, + }; +} diff --git a/src/claws/types.ts b/src/claws/types.ts index 9b65ab6cfb44..09dd40789bbe 100644 --- a/src/claws/types.ts +++ b/src/claws/types.ts @@ -241,6 +241,7 @@ export type ClawReadResult = clawMarkdownBody?: Buffer; packageBootstrap?: ClawWorkspaceSourceSnapshot; openClawProfile?: ClawOpenClawProfile; + legacyOpenClawProfile?: ClawOpenClawProfile; source: ClawSourceIdentity; snapshot: ClawSourceSnapshot; diagnostics: ClawDiagnostic[]; diff --git a/src/claws/update-capability-changes.test.ts b/src/claws/update-capability-changes.test.ts index d847bf7e93a1..2c21d2204e81 100644 --- a/src/claws/update-capability-changes.test.ts +++ b/src/claws/update-capability-changes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { materializeClawToolProfile } from "./tool-profile-consent.js"; import { cronCapabilityChange, mcpCapabilityChange, @@ -266,6 +267,70 @@ describe("pushResolvedAgentCapabilityChanges", () => { expect(removed).not.toContainEqual(expect.objectContaining({ path: "agent.tools.profile" })); }); + it("classifies growth in a frozen profile allowlist as an escalation", () => { + const changes = collectChanges({ + currentAgent: { + id: "worker", + tools: { allow: ["read", "write"] }, + }, + desiredAgent: { + id: "worker", + tools: { allow: ["read", "write", "apply_patch"] }, + }, + }); + + expect(changes).toContainEqual( + expect.objectContaining({ + path: "agent.tools.allow", + classification: "escalation", + requiresDistinctConsent: true, + }), + ); + }); + + it("does not escalate the one-time migration from a profile to its frozen allowlist", () => { + const desiredTools = materializeClawToolProfile({ + tools: { profile: "minimal", alsoAllow: ["cron"], deny: ["exec"] }, + }).tools; + const changes = collectChanges({ + currentAgent: { + id: "worker", + tools: { profile: "minimal", alsoAllow: ["cron"], deny: ["exec"] }, + }, + desiredAgent: { + id: "worker", + tools: desiredTools, + }, + }); + + expect(changes.filter((change) => change.path.startsWith("agent.tools."))).toEqual([]); + }); + + it("reports authority removed by freezing an inherited global alsoAllow grant", () => { + const desiredTools = materializeClawToolProfile({ + tools: { profile: "minimal" }, + }).tools; + const changes = collectChanges({ + currentAgent: { + id: "worker", + tools: { profile: "minimal" }, + }, + desiredAgent: { + id: "worker", + tools: desiredTools, + }, + tools: { alsoAllow: ["browser"] }, + }); + + expect(changes).toContainEqual( + expect.objectContaining({ + path: "agent.tools.allow", + classification: "reduction", + requiresDistinctConsent: false, + }), + ); + }); + it("classifies inherited profiles and wildcard reductions by effective capabilities", () => { const inheritedExpansion = collectChanges({ currentAgent: { id: "worker" }, diff --git a/src/claws/update-capability-changes.ts b/src/claws/update-capability-changes.ts index 195151758c4c..71e48976dbaa 100644 --- a/src/claws/update-capability-changes.ts +++ b/src/claws/update-capability-changes.ts @@ -8,6 +8,7 @@ import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { resolveRememberAcrossConversations } from "../memory-host-sdk/host/config-utils.js"; +import { resolveClawToolProfileSnapshot } from "./tool-profile-consent.js"; type ClawUpdateCapabilityValue = { summary: string; @@ -356,6 +357,44 @@ function pushAgentCapabilityChanges(params: { type AgentConfig = NonNullable["list"]>[number]; +function normalizeLegacyAgent( + config: OpenClawConfig, + currentAgent: AgentConfig, + desiredAgent: AgentConfig, +): AgentConfig { + const tools = currentAgent.tools; + if (!tools?.profile || desiredAgent.tools?.profile !== "full" || !desiredAgent.tools.allow) { + return currentAgent; + } + const snapshot = resolveClawToolProfileSnapshot({ + ...tools, + alsoAllow: ( + resolvePortableTools(config, currentAgent.id) as { + alsoAllow?: string[]; + } + ).alsoAllow, + }); + if (!snapshot) { + return currentAgent; + } + const { + profile: _profile, + allow: _allow, + alsoAllow: _alsoAllow, + deny: _deny, + ...otherTools + } = tools; + return { + ...currentAgent, + tools: { + ...otherTools, + profile: "full", + ...(snapshot.allow.length > 0 ? { allow: snapshot.allow } : {}), + ...(snapshot.deny.length > 0 ? { deny: snapshot.deny } : {}), + }, + }; +} + function resolveHeartbeat(config: OpenClawConfig, agentId: string): unknown { const defaults = config.agents?.defaults?.heartbeat; const overrides = listAgentEntries(config).find((agent) => agent.id === agentId)?.heartbeat; @@ -427,7 +466,14 @@ export function pushResolvedAgentCapabilityChanges(params: { }): void { const currentAgents = listAgentEntries(params.config); const currentIndex = currentAgents.findIndex((agent) => agent.id === params.agentId); - const currentAgent = currentIndex === -1 ? undefined : currentAgents[currentIndex]; + const existingCurrentAgent = currentIndex === -1 ? undefined : currentAgents[currentIndex]; + const currentAgent = existingCurrentAgent + ? normalizeLegacyAgent(params.config, existingCurrentAgent, params.desiredAgent) + : undefined; + const comparisonAgents = [...currentAgents]; + if (currentAgent && currentIndex !== -1) { + comparisonAgents[currentIndex] = currentAgent; + } const desiredAgents = [...currentAgents]; if (currentIndex === -1) { desiredAgents.push(params.desiredAgent); @@ -436,7 +482,7 @@ export function pushResolvedAgentCapabilityChanges(params: { } const currentConfig = prepareCapabilityComparisonConfig( params.config, - currentAgents, + comparisonAgents, params.agentId, ); const desiredConfig = prepareCapabilityComparisonConfig( @@ -459,7 +505,7 @@ export function pushResolvedAgentCapabilityChanges(params: { ? resolvePortableMemorySearch(params.config, params.agentId) : undefined, desiredMemorySearch: resolvePortableMemorySearch(desiredConfig, params.agentId), - currentTools: currentAgent ? resolvePortableTools(params.config, params.agentId) : undefined, + currentTools: currentAgent ? resolvePortableTools(currentConfig, params.agentId) : undefined, desiredTools: resolvePortableTools(desiredConfig, params.agentId), }); } diff --git a/src/cli/claws-cli-legacy-resume.test.ts b/src/cli/claws-cli-legacy-resume.test.ts new file mode 100755 index 000000000000..b4e10cdb20e5 --- /dev/null +++ b/src/cli/claws-cli-legacy-resume.test.ts @@ -0,0 +1,160 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { buildClawAddPlan } from "../claws/lifecycle.js"; +import { persistClawInstallRecord } from "../claws/provenance.js"; +import { readClawManifestFile } from "../claws/reader.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; + +const mocks = vi.hoisted(() => ({ + logs: [] as string[], + runtime: { + log: vi.fn(), + error: vi.fn(), + writeJson: vi.fn((value: unknown) => mocks.logs.push(JSON.stringify(value))), + writeStdout: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + }, + loadConfig: vi.fn<() => Record>(() => ({})), + listConfiguredMcpServers: vi.fn(), + applyClawAddPlan: vi.fn(), + preflightClawPackage: vi.fn(), +})); + +vi.mock("../runtime.js", async () => ({ + ...(await vi.importActual("../runtime.js")), + defaultRuntime: mocks.runtime, + writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown) => runtime.writeJson(value), +})); +vi.mock("../config/config.js", async () => ({ + ...(await vi.importActual("../config/config.js")), + getRuntimeConfig: mocks.loadConfig, +})); +vi.mock("../config/mcp-config.js", () => ({ + listConfiguredMcpServers: mocks.listConfiguredMcpServers, +})); +vi.mock("../claws/add.js", async () => ({ + ...(await vi.importActual("../claws/add.js")), + applyClawAddPlan: mocks.applyClawAddPlan, +})); +vi.mock("../claws/packages.js", async () => ({ + ...(await vi.importActual("../claws/packages.js")), + preflightClawPackage: mocks.preflightClawPackage, +})); + +const { runClawsAddCommand } = await import("./claws-cli.runtime.js"); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +beforeEach(() => { + vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1"); + mocks.logs.length = 0; + mocks.loadConfig.mockReset(); + mocks.listConfiguredMcpServers.mockResolvedValue({ ok: true, path: "config", mcpServers: {} }); + mocks.applyClawAddPlan.mockReset(); + mocks.applyClawAddPlan.mockResolvedValue({ + schemaVersion: "openclaw.clawAddResult.v1", + stability: "experimental", + status: "complete", + agent: { finalId: "demo-agent", workspace: "" }, + }); +}); + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + vi.unstubAllEnvs(); +}); + +describe("claws add legacy v1 resume", () => { + it.each(["coding", "minimal"] as const)( + "retries an exact committed dynamic %s-profile add through the bounded migration", + async (toolProfile) => { + const root = tempDirs.make("openclaw-claws-v1-profile-resume-"); + const workspace = join(root, "workspace"); + vi.stubEnv("OPENCLAW_STATE_DIR", join(tempDirs.make("openclaw-state-"), "state")); + await mkdir(join(root, "profiles")); + const manifestPath = join(root, "openclaw.claw.json"); + await writeFile( + manifestPath, + JSON.stringify({ schemaVersion: 1, agent: { id: "demo-agent", name: "Demo Agent" } }), + "utf8", + ); + await writeFile( + join(root, "profiles", "openclaw.yml"), + `schemaVersion: 1\nagent:\n tools:\n profile: ${toolProfile}\n`, + "utf8", + ); + const read = await readClawManifestFile(manifestPath, { + allowLegacyDynamicToolProfile: true, + }); + if (!read.ok || !read.legacyOpenClawProfile) { + throw new Error("expected legacy dynamic profile evidence"); + } + const legacyPlan = await buildClawAddPlan({ + manifest: read.manifest, + openClawProfile: read.legacyOpenClawProfile, + reconstructLegacyDynamicToolProfilePlan: true, + source: read.source, + context: { workspace, packagePreflight: mocks.preflightClawPackage }, + }); + persistClawInstallRecord(legacyPlan, { status: "workspace_ready", nowMs: 1 }); + openOpenClawStateDatabase() + .db /* sqlite-allow-raw: test-only downgrade simulates a pre-v2 interrupted add. */ + .prepare("UPDATE claw_installs SET schema_version = ? WHERE agent_id = ?") + .run("openclaw.clawInstallRecord.v1", "demo-agent"); + await mkdir(workspace); + let config = { agents: { list: [legacyPlan.agent.config] } }; + mocks.loadConfig.mockImplementation(() => config); + mocks.applyClawAddPlan.mockImplementationOnce(async (boundedPlan) => { + config = { agents: { list: [boundedPlan.agent.config] } }; + return { + schemaVersion: "openclaw.clawAddResult.v1", + stability: "experimental", + status: "partial", + agent: boundedPlan.agent, + }; + }); + + await expect( + runClawsAddCommand(manifestPath, { + yes: true, + planIntegrity: legacyPlan.planIntegrity, + workspace, + json: true, + }), + ).rejects.toThrow("__exit__:1"); + await runClawsAddCommand(manifestPath, { + yes: true, + planIntegrity: legacyPlan.planIntegrity, + workspace, + json: true, + }); + + expect(mocks.applyClawAddPlan).toHaveBeenLastCalledWith( + expect.objectContaining({ + planIntegrity: expect.not.stringMatching(legacyPlan.planIntegrity), + agent: expect.objectContaining({ + config: expect.objectContaining({ + tools: expect.objectContaining({ + profile: "full", + allow: expect.not.arrayContaining(["bundle-mcp"]), + }), + }), + }), + }), + expect.objectContaining({ + consentPlanIntegrity: legacyPlan.planIntegrity, + resumePlan: expect.objectContaining({ planIntegrity: legacyPlan.planIntegrity }), + resumeRecord: expect.objectContaining({ + schemaVersion: "openclaw.clawInstallRecord.v1", + }), + }), + ); + }, + ); +}); diff --git a/src/cli/claws-cli-legacy-resume.ts b/src/cli/claws-cli-legacy-resume.ts new file mode 100755 index 000000000000..43eaf4784758 --- /dev/null +++ b/src/cli/claws-cli-legacy-resume.ts @@ -0,0 +1,30 @@ +import { readClawInstallRecord, type PersistedClawInstall } from "../claws/provenance.js"; +import type { ClawManifest, ClawSourceIdentity } from "../claws/types.js"; +import type { ClawsAddOptions } from "./claws-cli.js"; + +export function authorizeLegacyV1Resume(params: { + manifest: ClawManifest; + source: Pick; + opts: ClawsAddOptions; +}): PersistedClawInstall | undefined { + const finalAgentId = params.opts.agentId?.trim() || params.manifest.agent?.id?.trim(); + const consentPlanIntegrity = params.opts.planIntegrity?.trim(); + if (!finalAgentId || !consentPlanIntegrity) { + return undefined; + } + const record = readClawInstallRecord(finalAgentId); + if ( + !record || + record.schemaVersion !== "openclaw.clawInstallRecord.v1" || + record.status === "complete" || + record.planIntegrity !== consentPlanIntegrity || + record.claw.kind !== params.source.kind || + record.claw.name !== params.source.name || + record.claw.version !== params.source.version || + record.claw.packageRoot !== params.source.packageRoot || + record.claw.manifestPath !== params.source.manifestPath + ) { + return undefined; + } + return record; +} diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index c94fd07f6bb5..9b893dae3223 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -38,6 +38,7 @@ import { clawInstallRecordMatchesPlan, readClawInstallRecord, readClawPackageRefs, + type PersistedClawInstall, } from "../claws/provenance.js"; import { readClawManifestFile } from "../claws/reader.js"; import { @@ -56,6 +57,7 @@ import { } from "../cron/store.js"; import { redactSensitiveText } from "../logging/redact.js"; import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +import { authorizeLegacyV1Resume } from "./claws-cli-legacy-resume.js"; import { waitUntilGatewayConfigApplied } from "./claws-cli.gateway-readiness.js"; import type { ClawsAddOptions, @@ -277,7 +279,13 @@ export async function runClawsAddCommand( if (failNonDryRun(opts, runtime)) { return; } - const result = await readClawManifestFile(sourcePath); + let legacyV1ResumeRecord: PersistedClawInstall | undefined; + const result = await readClawManifestFile(sourcePath, { + authorizeLegacyDynamicToolProfile: ({ manifest, source }) => { + legacyV1ResumeRecord = authorizeLegacyV1Resume({ manifest, source, opts }); + return legacyV1ResumeRecord !== undefined; + }, + }); if (!result.ok) { if (opts.json) { writeRuntimeJson(runtime, { @@ -323,9 +331,39 @@ export async function runClawsAddCommand( diagnostics: result.diagnostics, context: basePlanContext, }); - const resumeState = await matchingResumeState(plan, opts); + let legacyResumePlan = result.legacyOpenClawProfile + ? await buildClawAddPlan({ + manifest: result.manifest, + clawMarkdownBody: result.clawMarkdownBody, + packageBootstrap: result.packageBootstrap, + openClawProfile: result.legacyOpenClawProfile, + reconstructLegacyDynamicToolProfilePlan: true, + source: result.source, + diagnostics: result.diagnostics, + context: basePlanContext, + }) + : undefined; + let resumableInstallRecord: PersistedClawInstall | undefined; + const resumeState = await matchingResumeState(legacyResumePlan ?? plan, opts); + if (result.legacyOpenClawProfile && !resumeState) { + plan = { + ...plan, + blockers: [ + ...plan.blockers, + { + level: "error", + code: "claw_resume_plan_mismatch", + phase: "plan", + path: "$", + message: + "The incomplete Claw add no longer matches the previously consented plan; remove its partial state before retrying.", + }, + ], + }; + } if (resumeState) { const { record: resumeRecord, packageRefs: resumePackageRefs } = resumeState; + resumableInstallRecord = resumeRecord; const packagePreflight = async ( pkg: Parameters[0], workspace: string, @@ -342,12 +380,32 @@ export async function runClawsAddCommand( }; const canResumeWorkspace = resumeRecord.status === "workspace_ready" || resumeRecord.status === "config_committed"; + const expectedCommittedAgentConfigs = legacyResumePlan + ? [legacyResumePlan.agent.config, plan.agent.config] + : [plan.agent.config]; const committedAgent = listAgentEntries(config).find( - (agent) => stableStringify(agent) === stableStringify(plan.agent.config), + (agent) => + agent.id === resumeRecord.agentId && + expectedCommittedAgentConfigs.some( + (expected) => stableStringify(agent) === stableStringify(expected), + ), ); const canResumeAgent = resumeRecord.status === "config_committed" || (resumeRecord.status === "workspace_ready" && committedAgent !== undefined); + const resumePlanContext = { + ...basePlanContext, + packagePreflight, + existingAgentIds: canResumeAgent + ? existingAgentIds.filter((agentId) => agentId !== resumeRecord.agentId) + : existingAgentIds, + existingWorkspacePaths: canResumeWorkspace + ? existingAgentIds + .filter((agentId) => agentId !== resumeRecord.agentId) + .map((agentId) => resolveAgentWorkspaceDir(config, agentId)) + : existingWorkspacePaths, + ...(canResumeWorkspace ? { resumableWorkspace: resumeRecord.workspace } : {}), + }; plan = await buildClawAddPlan({ manifest: result.manifest, clawMarkdownBody: result.clawMarkdownBody, @@ -355,21 +413,29 @@ export async function runClawsAddCommand( openClawProfile: result.openClawProfile, source: result.source, diagnostics: result.diagnostics, - context: { - ...basePlanContext, - packagePreflight, - existingAgentIds: canResumeAgent - ? existingAgentIds.filter((agentId) => agentId !== resumeRecord.agentId) - : existingAgentIds, - existingWorkspacePaths: canResumeWorkspace - ? existingAgentIds - .filter((agentId) => agentId !== resumeRecord.agentId) - .map((agentId) => resolveAgentWorkspaceDir(config, agentId)) - : existingWorkspacePaths, - ...(canResumeWorkspace ? { resumableWorkspace: resumeRecord.workspace } : {}), - }, + context: resumePlanContext, }); - if (plan.blockers.length === 0 && !clawInstallRecordMatchesPlan(resumeRecord, plan)) { + if (result.legacyOpenClawProfile) { + legacyResumePlan = await buildClawAddPlan({ + manifest: result.manifest, + clawMarkdownBody: result.clawMarkdownBody, + packageBootstrap: result.packageBootstrap, + openClawProfile: result.legacyOpenClawProfile, + reconstructLegacyDynamicToolProfilePlan: true, + source: result.source, + diagnostics: result.diagnostics, + context: resumePlanContext, + }); + } + const expectedResumePlan = legacyResumePlan ?? plan; + const exactLegacyResume = + !legacyResumePlan || + (legacyV1ResumeRecord !== undefined && + stableStringify(legacyV1ResumeRecord) === stableStringify(resumeRecord)); + if ( + plan.blockers.length === 0 && + (!exactLegacyResume || !clawInstallRecordMatchesPlan(resumeRecord, expectedResumePlan)) + ) { plan = { ...plan, blockers: [ @@ -384,6 +450,8 @@ export async function runClawsAddCommand( }, ], }; + } else { + resumableInstallRecord = resumeRecord; } } @@ -410,7 +478,8 @@ export async function runClawsAddCommand( return; } - if (opts.planIntegrity !== plan.planIntegrity) { + const consentPlanIntegrity = legacyResumePlan?.planIntegrity ?? plan.planIntegrity; + if (opts.planIntegrity !== consentPlanIntegrity) { const message = "The consented Claw plan no longer matches; run add --dry-run again."; if (opts.json) { writeRuntimeJson(runtime, { @@ -431,6 +500,8 @@ export async function runClawsAddCommand( try { addResult = await applyClawAddPlan(plan, { consentPlanIntegrity: opts.planIntegrity, + resumeRecord: resumableInstallRecord, + resumePlan: legacyResumePlan, runtime: opts.json ? { ...runtime, log: () => undefined } : runtime, cronGateway: { add: async (input) => await callGatewayFromCli("cron.add", {}, input), diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index 90265f94bca3..be334f6c8e6d 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -852,6 +852,12 @@ describe("claws cli", () => { it("uses the source recorded by the installed Claw when --from is omitted", async () => { const { root } = await cliTestHelpers.writePackageFixture(tempDirs); + await mkdir(join(root, "profiles")); + await writeFile( + join(root, "profiles", "openclaw.yml"), + "schemaVersion: 1\nagent:\n tools:\n profile: coding\n", + "utf8", + ); mocks.readClawStatus.mockResolvedValue({ schemaVersion: "openclaw.clawStatus.v1", records: [ @@ -887,6 +893,14 @@ describe("claws cli", () => { expect.objectContaining({ agentId: "demo-agent", targetSource: expect.objectContaining({ name: "@acme/demo-agent", version: "1.2.3" }), + targetOpenClawProfile: expect.objectContaining({ + agent: { + tools: expect.objectContaining({ + profile: "full", + allow: expect.not.arrayContaining(["bundle-mcp"]), + }), + }, + }), }), ); }); diff --git a/src/cli/claws-update-cli.runtime.ts b/src/cli/claws-update-cli.runtime.ts index 4aae7f20299d..803f50e5ed0e 100644 --- a/src/cli/claws-update-cli.runtime.ts +++ b/src/cli/claws-update-cli.runtime.ts @@ -136,7 +136,9 @@ export async function runClawsUpdateCommand( source = recorded.kind === "package" ? recorded.packageRoot : recorded.manifestPath; } - const loaded = await readClawManifestFile(source); + const loaded = await readClawManifestFile(source, { + allowLegacyDynamicToolProfile: !opts.from, + }); if (!loaded.ok) { const diagnostics = opts.from ? loaded.diagnostics diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index ee10a3f22083..1659a72a0111 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -216,6 +216,7 @@ vi.mock("../../tasks/runtime-internal.js", () => ({ vi.mock("../../config/runtime-snapshot.js", () => ({ clearRuntimeConfigSnapshot: () => clearRuntimeConfigSnapshot(), + registerRuntimeConfigSnapshotPreparer: vi.fn(), })); vi.mock("../../tasks/task-registry.maintenance.js", () => ({ diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index c196166ea908..f9d36b3a723e 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -89,6 +89,7 @@ const setRuntimeConfigSnapshotMock = vi.hoisted(() => vi.fn<(cfg: OpenClawConfig, sourceConfig: OpenClawConfig) => void>(), ); vi.mock("../config/runtime-snapshot.js", () => ({ + registerRuntimeConfigSnapshotPreparer: vi.fn(), setRuntimeConfigSnapshot: setRuntimeConfigSnapshotMock, })); diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts index c99c7d22a4a2..17e66bd55b8b 100644 --- a/src/config/runtime-snapshot.ts +++ b/src/config/runtime-snapshot.ts @@ -119,6 +119,7 @@ const managedRuntimeConfigWriteOwners = new Map< Set<{ id: symbol; preflight?: ManagedRuntimeConfigWritePreflight }> >(); const runtimeConfigWriteListeners = new Set<(event: RuntimeConfigWriteNotification) => void>(); +const runtimeConfigSnapshotPreparers = new Set<(config: OpenClawConfig) => void>(); function stableConfigStringify(value: unknown): string { if (value === null || typeof value !== "object") { @@ -166,12 +167,25 @@ export function setRuntimeConfigSnapshot( config: OpenClawConfig, sourceConfig?: OpenClawConfig, ): void { + for (const prepare of runtimeConfigSnapshotPreparers) { + prepare(config); + } clearExecutablePathCache(); runtimeConfigSnapshot = config; runtimeConfigSourceSnapshot = sourceConfig ?? null; runtimeConfigSnapshotMetadata = createRuntimeConfigSnapshotMetadata(config, sourceConfig); } +export function registerRuntimeConfigSnapshotPreparer( + prepare: (config: OpenClawConfig) => void, +): () => void { + runtimeConfigSnapshotPreparers.add(prepare); + if (runtimeConfigSnapshot) { + prepare(runtimeConfigSnapshot); + } + return () => runtimeConfigSnapshotPreparers.delete(prepare); +} + export function setAppliedRuntimeConfigSnapshot( config: OpenClawConfig, sourceConfig: OpenClawConfig, diff --git a/src/plugins/tool-descriptor-cache.test.ts b/src/plugins/tool-descriptor-cache.test.ts index 1f6ba8c11ccd..e617e7381074 100644 --- a/src/plugins/tool-descriptor-cache.test.ts +++ b/src/plugins/tool-descriptor-cache.test.ts @@ -13,6 +13,7 @@ const hoisted = vi.hoisted(() => ({ })); vi.mock("../config/runtime-snapshot.js", () => ({ + registerRuntimeConfigSnapshotPreparer: vi.fn(), resolveRuntimeConfigCacheKey: hoisted.resolveRuntimeConfigCacheKey, })); diff --git a/src/state/openclaw-state-db-cache.ts b/src/state/openclaw-state-db-cache.ts index 15ea98d557de..c801440bbc0b 100644 --- a/src/state/openclaw-state-db-cache.ts +++ b/src/state/openclaw-state-db-cache.ts @@ -11,6 +11,29 @@ import type { OpenClawStateDatabase } from "./openclaw-state-db-contract.js"; import { createOpenClawDatabaseVerificationError } from "./openclaw-state-db-maintenance.js"; const cachedDatabases = new Map(); +type OpenClawStateDatabaseLifecycleEvent = + | { kind: "opened"; database: OpenClawStateDatabase } + | { kind: "closed"; path: string } + | { kind: "open-error"; path: string; error: unknown }; +const databaseLifecycleListeners = new Set<(event: OpenClawStateDatabaseLifecycleEvent) => void>(); + +function notifyOpenClawStateDatabaseLifecycle(event: OpenClawStateDatabaseLifecycleEvent): void { + for (const listener of databaseLifecycleListeners) { + listener(event); + } +} + +export function registerOpenClawStateDatabaseLifecycleListener( + listener: (event: OpenClawStateDatabaseLifecycleEvent) => void, +): () => void { + databaseLifecycleListeners.add(listener); + for (const database of cachedDatabases.values()) { + if (database.db.isOpen) { + listener({ kind: "opened", database }); + } + } + return () => databaseLifecycleListeners.delete(listener); +} type OpenClawStateDatabaseCloseResult = { caught: boolean; @@ -48,6 +71,7 @@ function evictCachedOpenClawStateDatabase(database: OpenClawStateDatabase): bool // Remove ownership before cleanup. A poisoned native handle can reject close, // but it must never remain discoverable as the process-wide shared handle. cachedDatabases.delete(database.path); + notifyOpenClawStateDatabaseLifecycle({ kind: "closed", path: database.path }); // Eviction is best-effort; the triggering database error remains authoritative. closeOpenClawStateDatabaseHandle(database); return true; @@ -74,6 +98,7 @@ const terminalOpenLatch = createSqliteTerminalOpenLatch({ function publishOpenClawStateDatabase(database: OpenClawStateDatabase): OpenClawStateDatabase { const { db, path: pathname } = database; cachedDatabases.set(pathname, database); + notifyOpenClawStateDatabaseLifecycle({ kind: "opened", database }); registerNodeSqliteKyselyQueryErrorHandler(db, (error) => { // Write transactions own rollback and evict at their outer boundary. if (!db.isTransaction && isSqliteCorruptionError(error)) { @@ -101,6 +126,7 @@ function closeStaleCachedOpenClawStateDatabase(database: OpenClawStateDatabase): database.walMaintenance.close(); clearNodeSqliteKyselyCacheForDatabase(database.db); cachedDatabases.delete(database.path); + notifyOpenClawStateDatabaseLifecycle({ kind: "closed", path: database.path }); } /** Latch background verification damage so later opens fail without rescanning. */ @@ -125,6 +151,10 @@ function assertOpenClawStateDatabaseOpenAllowed(pathname: string): void { } } +function recordOpenClawStateDatabaseLifecycleOpenError(pathname: string, error: unknown): void { + notifyOpenClawStateDatabaseLifecycle({ kind: "open-error", path: path.resolve(pathname), error }); +} + /** Reject a fresh shared-state open after known corruption until repair clears it. */ function assertOpenClawStateDatabaseFreshOpenAllowedAtPath( pathname: string, @@ -162,6 +192,7 @@ function closeOpenClawStateDatabaseByPath(pathname: string): boolean { database.db.close(); } cachedDatabases.delete(resolvedPath); + notifyOpenClawStateDatabaseLifecycle({ kind: "closed", path: resolvedPath }); return true; } @@ -172,6 +203,7 @@ function closeOpenClawStateDatabase(): void { if (database.db.isOpen) { database.db.close(); } + notifyOpenClawStateDatabaseLifecycle({ kind: "closed", path: database.path }); } cachedDatabases.clear(); } @@ -204,4 +236,5 @@ export const openClawStateDatabaseCache = { isOpenClawStateDatabaseOpen, publishOpenClawStateDatabase, recordOpenClawStateDatabaseOpenFailure, + recordOpenClawStateDatabaseLifecycleOpenError, }; diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 97f63c95614d..134b7628cbf5 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -41,6 +41,7 @@ import { VERSION } from "../version.js"; import { clearOpenClawDatabaseQuarantine } from "./openclaw-quarantine-store.js"; import { repairAuditEventsSchema } from "./openclaw-state-db-audit-migration.js"; import { openClawStateDatabaseCache as stateDbCache } from "./openclaw-state-db-cache.js"; +export { registerOpenClawStateDatabaseLifecycleListener } from "./openclaw-state-db-cache.js"; import { OPENCLAW_DATABASE_SCHEMA_DOCS_URL, LAZY_ADDITIVE_STATE_TABLES, @@ -576,13 +577,23 @@ export function openOpenClawStateDatabase( const pathname = resolveDatabasePath(options); // Latched paths are quarantined: the recorder closed any live handle, and // every open fails fast here until doctor repairs the file and clears it. - stateDbCache.assertOpenClawStateDatabaseOpenAllowed(pathname); + try { + stateDbCache.assertOpenClawStateDatabaseOpenAllowed(pathname); + } catch (error) { + stateDbCache.recordOpenClawStateDatabaseLifecycleOpenError(pathname, error); + throw error; + } const cached = stateDbCache.getCachedOpenClawStateDatabase(pathname); if (cached?.db.isOpen) { assertOpenClawStateWriteAllowed({ database: cached.db, databasePath: pathname, env }); return cached; } - assertOpenClawStateDatabaseFreshOpenAllowed(options); + try { + assertOpenClawStateDatabaseFreshOpenAllowed(options); + } catch (error) { + stateDbCache.recordOpenClawStateDatabaseLifecycleOpenError(pathname, error); + throw error; + } let unpublished: OpenClawStateDatabase | undefined; try { unpublished = runWithOpenClawStateWriteAccess( @@ -597,6 +608,7 @@ export function openOpenClawStateDatabase( }, ); } catch (error) { + stateDbCache.recordOpenClawStateDatabaseLifecycleOpenError(pathname, error); if (!unpublished) { throw error; }