mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"f2752fd306b35b124b0cd32b732711be0f9831f10d13f9fcaacd4ecb39cce9e3","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"108af57a4b4bc57c70912fc40319876bb6ec994cce59d2b9c991980e18c4f80f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"6fc88598c472783c5cd49a44a55c7ea918438eb540837772d68bbe6928e68495","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"dd255204bf11dccfe9d866e7a6725935bfead7378a52ddfa6b60b9a58c0cdf76","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"4cd42b2807c10408d0067265697f27a18bce6a1de9a4988e1a2e879f87c74598","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
{"contentHash":"f0be441226a760f75dc1634f93d7f28586a0b18f690e2dd0f87c5360f2cb7f03","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"dc48f51312725277ee6004c6bfe43d42d16dae0e7e0fe9371a66f4b7a859df76","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"8bf7d1fd1e21861fd472365e0ab10ca811800e1b0cb9e6aac0e4d6a41613a9a2","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"0dff4add5bfb93c182a0c0b3f3ee471e7fbf69d9d0e1f6a342668451a4620c18","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"d430c3def27acb48a5356dafac17b89b0cbfcb1d3f959136b8c7ae82586d87df","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"15b6006cb8edd215e6df21c5c8250a25635661b6633b0a858c50ed019554aa50","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"4c667b7e88cd752c294bcc731bc157c1ccb2a7aa4afc58f4013409191f3a9b28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"751c9fe121607e061b2eb3e1c57fc854d650ab583873979746cf25637c551669","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"027b2280171324b628865fb4cdf9f72c13c059385a2fbec5b650c69d66beecdc","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"8307497b019c13f8ce6d3159a07a4df516d6f84b7219b2ab1666ceeb3481fa61","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"69fe3479e42b74771fb86d228264e8e19a3a16e1a07df4f89499111c2df2bc13","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"52e9b62ba55034c21c4673e2acba9c6a367d75c46e92cff698f2a6720008c17e","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"f058be9cbffbcb6e1dafe69cbc701c7cc06d1ffc90323256685e31b111f91193","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"0da095103f904be5e77d287c93a739b33f192c96e0f5334571ad46245a9ecba5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"e7ac61e29d41e43a4c780831a8aa0e5bed10e47630a36879f2555216eb1cce54","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"b9c11e37dd3647431225b8fdd97d6402074a741c80f2fa3e54745bade5fcc6b7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"f76d5908b66bb49851cccc530dc63fc5f3352b327963f1091a0f34fe9ccc693b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"307513192612557813825c129772eb642c0f4aac97ba538938dd4f6e9efd60d9","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"e9dd8d432098b26f2e102fe41edee7c556558ea9b26a0077da72b0967d6649d2","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
+13
-7
@@ -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.
|
||||
|
||||
@@ -345,6 +345,7 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../config/runtime-snapshot.js", () => ({
|
||||
registerRuntimeConfigSnapshotPreparer: vi.fn(),
|
||||
setRuntimeConfigSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TPolicy extends ToolPolicyLike>(
|
||||
policy: TPolicy | undefined,
|
||||
alsoAllow: readonly string[] | undefined,
|
||||
): TPolicy | undefined {
|
||||
if (isFrozenClawToolAllowPolicy(policy)) {
|
||||
return policy;
|
||||
}
|
||||
return mergeAlsoAllowPolicy(policy, alsoAllow ? [...alsoAllow] : undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"] } },
|
||||
|
||||
@@ -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<TTool extends { name: string }>(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<TTool extends { name: string }>(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;
|
||||
}
|
||||
|
||||
Executable
+44
@@ -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<ClawInstallStatus, number> = {
|
||||
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);
|
||||
}
|
||||
Executable
+190
@@ -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<typeof persistClawInstallRecord>()
|
||||
.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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+39
-43
@@ -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<void>;
|
||||
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<ClawInstallStatus, number> = {
|
||||
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",
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
+19
-16
@@ -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"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
+13
-1
@@ -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<ClawOpenClawProfile["agent"]["tools"]> = 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 } }
|
||||
|
||||
Executable
+44
@@ -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];
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string, unknown> | undefined {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<Buffer> {
|
||||
const packageFiles = await fsSafeRoot(packageRoot);
|
||||
const read = await packageFiles.read(path, {
|
||||
@@ -102,10 +182,12 @@ async function readProfileFile(packageRoot: string, path: string): Promise<Buffe
|
||||
export async function readClawOpenClawProfile(params: {
|
||||
packageRoot: string;
|
||||
metadata?: Record<string, string>;
|
||||
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 } : {}),
|
||||
|
||||
@@ -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<typeof parseClawInstallRecordSchemaVersion>;
|
||||
agentConfigDigest: string;
|
||||
}
|
||||
| { kind: "error"; error: unknown };
|
||||
|
||||
type ClawInstallSchemaVersionSnapshot =
|
||||
| { kind: "ready"; schemaVersions: Map<string, ClawInstallSchemaVersionRead> }
|
||||
| {
|
||||
kind: "state-error";
|
||||
error: unknown;
|
||||
knownAgentIds: ReadonlySet<string>;
|
||||
ownershipUnknown: boolean;
|
||||
}
|
||||
| { kind: "uninitialized" };
|
||||
|
||||
// Install provenance is process-stable; only the state lifecycle and Claw mutations refresh it.
|
||||
const snapshotsByPath = new Map<string, ClawInstallSchemaVersionSnapshot>();
|
||||
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<string, ClawInstallSchemaVersionRead>();
|
||||
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<string> {
|
||||
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<typeof parseClawInstallRecordSchemaVersion>,
|
||||
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();
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TRecord, "planIntegrity" | "agentConfigDigest">,
|
||||
): Omit<TRecord, "schemaVersion"> & { 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+66
-89
@@ -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<typeof installRecordSchema.parseClawInstallRecordSchemaVersion>;
|
||||
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(
|
||||
|
||||
+29
-1
@@ -597,7 +597,19 @@ async function resolveSource(
|
||||
};
|
||||
}
|
||||
|
||||
export async function readClawManifestFile(path: string): Promise<ClawReadResult> {
|
||||
export async function readClawManifestFile(
|
||||
path: string,
|
||||
options: {
|
||||
allowLegacyDynamicToolProfile?: boolean;
|
||||
authorizeLegacyDynamicToolProfile?: (params: {
|
||||
manifest: ClawManifest;
|
||||
source: Pick<
|
||||
ClawSourceIdentity,
|
||||
"kind" | "name" | "version" | "packageRoot" | "manifestPath"
|
||||
>;
|
||||
}) => boolean | Promise<boolean>;
|
||||
} = {},
|
||||
): Promise<ClawReadResult> {
|
||||
const sourceResult = await resolveSource(path);
|
||||
if (!sourceResult.ok) {
|
||||
return sourceResult;
|
||||
@@ -628,9 +640,24 @@ export async function readClawManifestFile(path: string): Promise<ClawReadResult
|
||||
],
|
||||
};
|
||||
}
|
||||
const allowLegacyDynamicToolProfile =
|
||||
options.allowLegacyDynamicToolProfile === true ||
|
||||
(options.authorizeLegacyDynamicToolProfile
|
||||
? await options.authorizeLegacyDynamicToolProfile({
|
||||
manifest: parsed.manifest,
|
||||
source: {
|
||||
kind: sourceResult.source.kind,
|
||||
name: sourceResult.source.name,
|
||||
version: sourceResult.source.version,
|
||||
packageRoot: sourceResult.source.packageRoot,
|
||||
manifestPath: sourceResult.source.manifestPath,
|
||||
},
|
||||
})
|
||||
: false);
|
||||
const profile = await readClawOpenClawProfile({
|
||||
packageRoot: sourceResult.source.packageRoot,
|
||||
metadata: parsed.manifest.metadata,
|
||||
...(allowLegacyDynamicToolProfile ? { allowLegacyDynamicToolProfile: true } : {}),
|
||||
});
|
||||
if (!profile.ok) {
|
||||
return profile;
|
||||
@@ -663,6 +690,7 @@ export async function readClawManifestFile(path: string): Promise<ClawReadResult
|
||||
...(hasMarkdownBody ? { clawMarkdownBody: manifestResult.body } : {}),
|
||||
...(snapshot.packageBootstrap ? { packageBootstrap: snapshot.packageBootstrap } : {}),
|
||||
...(profile.profile ? { openClawProfile: profile.profile } : {}),
|
||||
...(profile.legacyProfile ? { legacyOpenClawProfile: profile.legacyProfile } : {}),
|
||||
source,
|
||||
snapshot: {
|
||||
manifest: snapshot.manifest,
|
||||
|
||||
+75
-4
@@ -1,6 +1,11 @@
|
||||
// Strict parser for grouped Claw schema version 1 manifests.
|
||||
import { z } from "zod";
|
||||
import { resolveToolProfilePolicy } from "../agents/tool-policy-shared.js";
|
||||
import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js";
|
||||
import {
|
||||
expandToolGroups,
|
||||
normalizeToolPolicyName,
|
||||
resolveToolProfilePolicy,
|
||||
} from "../agents/tool-policy-shared.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { computeNextRunAtMs } from "../cron/schedule.js";
|
||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||
@@ -15,6 +20,10 @@ import {
|
||||
isValidClawTimezone,
|
||||
portableClawPathKey,
|
||||
} from "./schema-portability.js";
|
||||
import {
|
||||
isConcreteBundleMcpToolName,
|
||||
resolveClawToolProfileSnapshot,
|
||||
} from "./tool-profile-consent.js";
|
||||
import {
|
||||
CLAW_BOOTSTRAP_FILE_NAMES,
|
||||
CLAW_SCHEMA_VERSION,
|
||||
@@ -32,6 +41,18 @@ const nonEmptyString = z
|
||||
);
|
||||
const optionalString = nonEmptyString.optional();
|
||||
|
||||
function isBoundedClawToolGrant(value: string): boolean {
|
||||
const normalized = normalizeToolPolicyName(value);
|
||||
if (
|
||||
/[*?[\]{}]/u.test(normalized) ||
|
||||
normalized === "group:plugins" ||
|
||||
normalized === "bundle-mcp"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !normalized.startsWith("group:") || expandToolGroups([normalized])[0] !== normalized;
|
||||
}
|
||||
|
||||
export function clawManifestWorkspaceConflictsWithPath(
|
||||
manifest: ClawManifest,
|
||||
path: string,
|
||||
@@ -127,8 +148,14 @@ const openClawProfileSchema = z
|
||||
"Tool profile must name a registered OpenClaw built-in profile.",
|
||||
)
|
||||
.optional(),
|
||||
allow: z.array(nonEmptyString).min(1).optional(),
|
||||
alsoAllow: z.array(nonEmptyString).min(1).optional(),
|
||||
allow: z
|
||||
.array(nonEmptyString.refine(isBoundedClawToolGrant, "Tool grants must be bounded."))
|
||||
.min(1)
|
||||
.optional(),
|
||||
alsoAllow: z
|
||||
.array(nonEmptyString.refine(isBoundedClawToolGrant, "Tool grants must be bounded."))
|
||||
.min(1)
|
||||
.optional(),
|
||||
deny: z.array(nonEmptyString).min(1).optional(),
|
||||
fs: z
|
||||
.object({ workspaceOnly: z.literal(true).optional() })
|
||||
@@ -137,6 +164,49 @@ const openClawProfileSchema = z
|
||||
})
|
||||
.strict()
|
||||
.superRefine((tools, ctx) => {
|
||||
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<string>();
|
||||
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"],
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<object>();
|
||||
type PreparedClawToolPolicy =
|
||||
| { kind: "current" }
|
||||
| { kind: "legacy" }
|
||||
| { kind: "state-error"; error: unknown };
|
||||
const preparedClawToolPolicies = new WeakMap<object, PreparedClawToolPolicy>();
|
||||
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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ClawOpenClawProfile["agent"]["tools"]>;
|
||||
type ClawToolProfileSelection = Omit<
|
||||
Pick<ClawToolSettings, "profile" | "allow" | "alsoAllow" | "deny">,
|
||||
"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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -241,6 +241,7 @@ export type ClawReadResult =
|
||||
clawMarkdownBody?: Buffer;
|
||||
packageBootstrap?: ClawWorkspaceSourceSnapshot;
|
||||
openClawProfile?: ClawOpenClawProfile;
|
||||
legacyOpenClawProfile?: ClawOpenClawProfile;
|
||||
source: ClawSourceIdentity;
|
||||
snapshot: ClawSourceSnapshot;
|
||||
diagnostics: ClawDiagnostic[];
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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<NonNullable<OpenClawConfig["agents"]>["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),
|
||||
});
|
||||
}
|
||||
|
||||
Executable
+160
@@ -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<string, unknown>>(() => ({})),
|
||||
listConfiguredMcpServers: vi.fn(),
|
||||
applyClawAddPlan: vi.fn(),
|
||||
preflightClawPackage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../runtime.js")>("../runtime.js")),
|
||||
defaultRuntime: mocks.runtime,
|
||||
writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown) => runtime.writeJson(value),
|
||||
}));
|
||||
vi.mock("../config/config.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../config/config.js")>("../config/config.js")),
|
||||
getRuntimeConfig: mocks.loadConfig,
|
||||
}));
|
||||
vi.mock("../config/mcp-config.js", () => ({
|
||||
listConfiguredMcpServers: mocks.listConfiguredMcpServers,
|
||||
}));
|
||||
vi.mock("../claws/add.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../claws/add.js")>("../claws/add.js")),
|
||||
applyClawAddPlan: mocks.applyClawAddPlan,
|
||||
}));
|
||||
vi.mock("../claws/packages.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../claws/packages.js")>("../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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
Executable
+30
@@ -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<ClawSourceIdentity, "kind" | "name" | "version" | "packageRoot" | "manifestPath">;
|
||||
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;
|
||||
}
|
||||
@@ -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<typeof preflightClawPackage>[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),
|
||||
|
||||
@@ -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"]),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../config/runtime-snapshot.js", () => ({
|
||||
registerRuntimeConfigSnapshotPreparer: vi.fn(),
|
||||
resolveRuntimeConfigCacheKey: hoisted.resolveRuntimeConfigCacheKey,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<string, OpenClawStateDatabase>();
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user