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:
Gio Della-Libera
2026-08-12 11:37:04 -07:00
committed by GitHub
parent 343850d0df
commit 1ec4e4582e
63 changed files with 2596 additions and 226 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
{"contentHash":"15b6006cb8edd215e6df21c5c8250a25635661b6633b0a858c50ed019554aa50","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"4c667b7e88cd752c294bcc731bc157c1ccb2a7aa4afc58f4013409191f3a9b28","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
}));
+16 -1
View File
@@ -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);
}
+28
View File
@@ -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"] } },
+9 -1
View File
@@ -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;
}
+44
View File
@@ -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);
}
+190
View File
@@ -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
View File
@@ -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",
+7
View File
@@ -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")}`;
}
+1 -1
View File
@@ -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
View File
@@ -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"),
}),
);
},
);
});
+76 -8
View File
@@ -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
View File
@@ -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 } }
+44
View File
@@ -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 -4
View File
@@ -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);
+4
View File
@@ -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);
}
}
+6 -1
View File
@@ -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,
};
+109 -6
View File
@@ -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",
);
+87 -1
View File
@@ -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 } : {}),
+193
View File
@@ -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();
}
+135
View File
@@ -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",
});
});
});
+55
View File
@@ -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,
};
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]);
});
});
+28
View File
@@ -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 });
});
});
+168
View File
@@ -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 };
}
+152
View File
@@ -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");
});
});
+98
View File
@@ -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 } : {}),
},
};
}
+1
View File
@@ -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" },
+49 -3
View File
@@ -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),
});
}
+160
View File
@@ -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",
}),
}),
);
},
);
});
+30
View File
@@ -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;
}
+89 -18
View File
@@ -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),
+14
View File
@@ -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"]),
}),
},
}),
}),
);
});
+3 -1
View File
@@ -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
+1
View File
@@ -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,
}));
+14
View File
@@ -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,
}));
+33
View File
@@ -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,
};
+14 -2
View File
@@ -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;
}