mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(cron): persist explicit scheduled tool authority (#112483)
* fix(cron): persist explicit scheduled tool authority * test(cron): cover explicit scheduled authority * fix(cron): preserve legacy authority on routine edits * test(cron): add explicit authority e2e matrix * test(cron): harden explicit authority live proof
This commit is contained in:
+256
-65
@@ -53,9 +53,13 @@ dump_logs_on_error() {
|
||||
if [ "$status" -ne 0 ]; then
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/cron-cli-gateway.log \
|
||||
/tmp/cron-cli-device-seed.json \
|
||||
/tmp/cron-cli-status.json \
|
||||
/tmp/cron-cli-add.json \
|
||||
/tmp/cron-cli-agent-add.json \
|
||||
/tmp/cron-cli-agent-default.json \
|
||||
/tmp/cron-cli-agent-restricted.json \
|
||||
/tmp/cron-cli-agent-cleared.json \
|
||||
/tmp/cron-authority-operator-matrix.json \
|
||||
/tmp/cron-cli-edit-exact.json \
|
||||
/tmp/cron-cli-edit-timeout.json \
|
||||
/tmp/cron-cli-get-after-edit.json \
|
||||
@@ -78,78 +82,214 @@ cron_cli() {
|
||||
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
|
||||
}
|
||||
|
||||
seed_paired_cli_device() {
|
||||
node --input-type=module <<'NODE'
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
run_operator_authority_matrix() {
|
||||
local phase="$1"
|
||||
node --input-type=module - "$entry" "${GW_TOKEN:?missing GW_TOKEN}" "$phase" <<'NODE'
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
async function importDistChunk(prefix, marker) {
|
||||
const distDir = join(process.cwd(), "dist");
|
||||
const entries = await readdir(distDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = join(distDir, entry);
|
||||
if ((await readFile(fullPath, "utf8")).includes(marker)) {
|
||||
return await import(pathToFileURL(fullPath).href);
|
||||
}
|
||||
const [entry, token, phase] = process.argv.slice(2);
|
||||
const snapshotPath = "/tmp/cron-authority-operator-matrix.json";
|
||||
|
||||
function callGateway(method, params) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[entry, "gateway", "call", method, "--params", JSON.stringify(params), "--token", token, "--json"],
|
||||
{ encoding: "utf8", env: process.env },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${method} failed (${result.status}): ${String(result.stderr || result.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
|
||||
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
|
||||
const loadOrCreateDeviceIdentity =
|
||||
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
|
||||
const publicKeyRawBase64UrlFromPem =
|
||||
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
|
||||
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
|
||||
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
|
||||
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
|
||||
|
||||
if (
|
||||
typeof loadOrCreateDeviceIdentity !== "function" ||
|
||||
typeof publicKeyRawBase64UrlFromPem !== "function" ||
|
||||
typeof approveDevicePairing !== "function" ||
|
||||
typeof getPairedDevice !== "function" ||
|
||||
typeof requestDevicePairing !== "function"
|
||||
) {
|
||||
throw new Error("missing device pairing exports in dist chunks");
|
||||
function readAuthority(job) {
|
||||
return {
|
||||
toolsAllow: job.payload?.toolsAllow,
|
||||
toolsAllowIsDefault: job.payload?.toolsAllowIsDefault,
|
||||
};
|
||||
}
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const requiredScopes = ["operator.admin"];
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const pairedScopes = Array.isArray(paired?.approvedScopes)
|
||||
? paired.approvedScopes
|
||||
: Array.isArray(paired?.scopes)
|
||||
? paired.scopes
|
||||
: [];
|
||||
|
||||
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
|
||||
const pairing = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey,
|
||||
displayName: "cron cli docker smoke",
|
||||
platform: process.platform,
|
||||
clientId: "cli",
|
||||
clientMode: "cli",
|
||||
role: "operator",
|
||||
scopes: requiredScopes,
|
||||
silent: true,
|
||||
});
|
||||
const approved = await approveDevicePairing(pairing.request.requestId, {
|
||||
callerScopes: requiredScopes,
|
||||
});
|
||||
if (approved?.status !== "approved") {
|
||||
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
|
||||
function assertAuthority(label, job, expected) {
|
||||
const actual = readAuthority(job);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`${label} authority mismatch: expected=${JSON.stringify(expected)} actual=${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
|
||||
if (phase === "create") {
|
||||
const schedule = { kind: "every", everyMs: 3_600_000 };
|
||||
const cases = [
|
||||
{
|
||||
label: "agent-default",
|
||||
input: {
|
||||
name: "operator agent default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "agent default" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "agent-wildcard",
|
||||
input: {
|
||||
name: "operator agent wildcard",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "agent wildcard", toolsAllow: ["*"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "agent-empty",
|
||||
input: {
|
||||
name: "operator agent empty",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "agent empty", toolsAllow: [] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: [] },
|
||||
},
|
||||
{
|
||||
label: "script-default",
|
||||
input: {
|
||||
name: "operator script default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "script", script: "return {}" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "trigger-system-default",
|
||||
input: {
|
||||
name: "operator trigger system default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
trigger: { script: "return { fire: false }" },
|
||||
payload: { kind: "systemEvent", text: "trigger system default" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "trigger-command-default",
|
||||
input: {
|
||||
name: "operator trigger command default",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
trigger: { script: "return { fire: false }" },
|
||||
payload: { kind: "command", argv: ["printf", "trigger-command"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["*"] },
|
||||
},
|
||||
{
|
||||
label: "transport-system-capless",
|
||||
input: {
|
||||
name: "operator transport system capless",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "transport system capless" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: {},
|
||||
},
|
||||
{
|
||||
label: "transport-command-capless",
|
||||
input: {
|
||||
name: "operator transport command capless",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "command", argv: ["printf", "transport-command"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: {},
|
||||
},
|
||||
{
|
||||
label: "transport-system-narrow-trigger",
|
||||
input: {
|
||||
name: "operator transport system narrow",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "transport system narrow", toolsAllow: ["read"] },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: { toolsAllow: ["read"] },
|
||||
patch: { trigger: { script: "return { fire: false }" } },
|
||||
expectedAfterPatch: { toolsAllow: ["read"] },
|
||||
},
|
||||
{
|
||||
label: "transport-system-capless-trigger",
|
||||
input: {
|
||||
name: "operator transport system adopts wildcard",
|
||||
enabled: false,
|
||||
schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "transport system adopts wildcard" },
|
||||
delivery: { mode: "none" },
|
||||
},
|
||||
expected: {},
|
||||
patch: { trigger: { script: "return { fire: false }" } },
|
||||
expectedAfterPatch: { toolsAllow: ["*"] },
|
||||
},
|
||||
];
|
||||
|
||||
const snapshots = [];
|
||||
for (const testCase of cases) {
|
||||
let job = callGateway("cron.add", testCase.input);
|
||||
assertAuthority(`${testCase.label} create`, job, testCase.expected);
|
||||
if (testCase.patch) {
|
||||
callGateway("cron.update", { id: job.id, patch: testCase.patch });
|
||||
job = callGateway("cron.get", { id: job.id });
|
||||
assertAuthority(`${testCase.label} update`, job, testCase.expectedAfterPatch);
|
||||
}
|
||||
snapshots.push({
|
||||
id: job.id,
|
||||
label: testCase.label,
|
||||
authority: readAuthority(job),
|
||||
});
|
||||
}
|
||||
await writeFile(snapshotPath, `${JSON.stringify({ cases: snapshots }, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(`operator authority matrix created ${snapshots.length} cases\n`);
|
||||
} else if (phase === "verify") {
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
for (const testCase of snapshot.cases) {
|
||||
const job = callGateway("cron.get", { id: testCase.id });
|
||||
assertAuthority(`${testCase.label} restart`, job, testCase.authority);
|
||||
callGateway("cron.remove", { id: testCase.id });
|
||||
}
|
||||
process.stdout.write(`operator authority matrix restart-verified ${snapshot.cases.length} cases\n`);
|
||||
} else {
|
||||
throw new Error(`unknown authority matrix phase: ${phase}`);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
@@ -167,10 +307,29 @@ read_json_field() {
|
||||
' "$file" "$field"
|
||||
}
|
||||
|
||||
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is required");
|
||||
}
|
||||
const raw = await fs.readFile(configPath, "utf8").catch(() => "{}");
|
||||
const config = JSON.parse(raw || "{}");
|
||||
config.cron ??= {};
|
||||
config.cron.triggers = { ...(config.cron.triggers ?? {}), enabled: true };
|
||||
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
'
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
|
||||
|
||||
run_operator_authority_matrix create
|
||||
openclaw_e2e_stop_process "$gateway_pid"
|
||||
gateway_pid=
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
|
||||
run_operator_authority_matrix verify
|
||||
|
||||
cron_cli status --json > /tmp/cron-cli-status.json
|
||||
cron_add_args=(
|
||||
"cli cron smoke"
|
||||
@@ -184,6 +343,38 @@ cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
|
||||
|
||||
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
|
||||
|
||||
cron_cli add \
|
||||
"agent authority smoke" \
|
||||
--every 1h \
|
||||
--session isolated \
|
||||
--message "verify explicit cron tool authority" \
|
||||
--no-deliver \
|
||||
--json > /tmp/cron-cli-agent-add.json
|
||||
agent_job_id="$(read_json_field /tmp/cron-cli-agent-add.json id)"
|
||||
|
||||
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-default.json
|
||||
cron_cli edit "$agent_job_id" --tools read
|
||||
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-restricted.json
|
||||
cron_cli edit "$agent_job_id" --clear-tools
|
||||
cron_cli show "$agent_job_id" --json > /tmp/cron-cli-agent-cleared.json
|
||||
node --input-type=module -e '
|
||||
const fs = await import("node:fs/promises");
|
||||
const readPayload = async (path) => JSON.parse(await fs.readFile(path, "utf8")).payload;
|
||||
const defaultPayload = await readPayload("/tmp/cron-cli-agent-default.json");
|
||||
const restrictedPayload = await readPayload("/tmp/cron-cli-agent-restricted.json");
|
||||
const clearedPayload = await readPayload("/tmp/cron-cli-agent-cleared.json");
|
||||
if (JSON.stringify(defaultPayload?.toolsAllow) !== JSON.stringify(["*"])) {
|
||||
throw new Error(`new agent job is not explicitly unrestricted: ${JSON.stringify(defaultPayload)}`);
|
||||
}
|
||||
if (JSON.stringify(restrictedPayload?.toolsAllow) !== JSON.stringify(["read"])) {
|
||||
throw new Error(`cron edit --tools did not persist: ${JSON.stringify(restrictedPayload)}`);
|
||||
}
|
||||
if (JSON.stringify(clearedPayload?.toolsAllow) !== JSON.stringify(["*"])) {
|
||||
throw new Error(`cron edit --clear-tools is not explicit: ${JSON.stringify(clearedPayload)}`);
|
||||
}
|
||||
'
|
||||
cron_cli rm "$agent_job_id" --json >/dev/null
|
||||
|
||||
cron_cli edit "$job_id" --exact > /tmp/cron-cli-edit-exact.json
|
||||
cron_cli edit "$job_id" --timeout-seconds 30 > /tmp/cron-cli-edit-timeout.json
|
||||
cron_cli get "$job_id" > /tmp/cron-cli-get-after-edit.json
|
||||
|
||||
Reference in New Issue
Block a user