mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 11:21:34 +00:00
* 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
460 lines
15 KiB
Bash
Executable File
460 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Starts a packaged Gateway in Docker and verifies public cron CLI CRUD/run flows.
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-cli-e2e" OPENCLAW_IMAGE)"
|
|
PORT="18789"
|
|
TOKEN="cron-cli-e2e-$(date +%s)-$$"
|
|
CONTAINER_NAME="openclaw-cron-cli-e2e-$$"
|
|
CLIENT_LOG="$(mktemp -t openclaw-cron-cli-log.XXXXXX)"
|
|
|
|
cleanup() {
|
|
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
|
rm -f "$CLIENT_LOG"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-cli
|
|
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-cli empty)"
|
|
|
|
echo "Running in-container Gateway + cron CLI smoke..."
|
|
set +e
|
|
docker_e2e_run_with_harness \
|
|
--name "$CONTAINER_NAME" \
|
|
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
|
|
-e "OPENCLAW_SKIP_CHANNELS=1" \
|
|
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
|
|
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
|
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
|
|
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
|
|
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
|
-e "GW_TOKEN=$TOKEN" \
|
|
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
|
|
-i \
|
|
"$IMAGE_NAME" \
|
|
bash -s >"$CLIENT_LOG" 2>&1 <<'INNER'
|
|
set -euo pipefail
|
|
|
|
source scripts/lib/openclaw-e2e-instance.sh
|
|
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
|
|
|
entry="$(openclaw_e2e_resolve_entrypoint)"
|
|
gateway_pid=
|
|
|
|
cleanup_inner() {
|
|
openclaw_e2e_stop_process "${gateway_pid:-}"
|
|
}
|
|
|
|
dump_logs_on_error() {
|
|
status=$?
|
|
if [ "$status" -ne 0 ]; then
|
|
openclaw_e2e_dump_logs \
|
|
/tmp/cron-cli-gateway.log \
|
|
/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 \
|
|
/tmp/cron-cli-list.json \
|
|
/tmp/cron-cli-show.json \
|
|
/tmp/cron-cli-disable.json \
|
|
/tmp/cron-cli-enable.json \
|
|
/tmp/cron-cli-run.json \
|
|
/tmp/cron-cli-runs.json \
|
|
/tmp/cron-cli-remove.json
|
|
fi
|
|
cleanup_inner
|
|
exit "$status"
|
|
}
|
|
|
|
trap cleanup_inner EXIT
|
|
trap dump_logs_on_error ERR
|
|
|
|
cron_cli() {
|
|
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
|
|
}
|
|
|
|
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";
|
|
|
|
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()}`,
|
|
);
|
|
}
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
function readAuthority(job) {
|
|
return {
|
|
toolsAllow: job.payload?.toolsAllow,
|
|
toolsAllowIsDefault: job.payload?.toolsAllowIsDefault,
|
|
};
|
|
}
|
|
|
|
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)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
read_json_field() {
|
|
local file="$1"
|
|
local field="$2"
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const [file, field] = process.argv.slice(1);
|
|
const value = JSON.parse(await fs.readFile(file, "utf8"))[field];
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new Error(`missing string field ${field} in ${file}`);
|
|
}
|
|
process.stdout.write(value);
|
|
' "$file" "$field"
|
|
}
|
|
|
|
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"
|
|
--cron "*/5 * * * *"
|
|
--command "printf openclaw-cli-cron-ok"
|
|
--no-deliver
|
|
--timeout-seconds 15
|
|
--json
|
|
)
|
|
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
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-get-after-edit.json", "utf8"));
|
|
if (value.schedule?.kind !== "cron" || value.schedule.staggerMs !== 0) {
|
|
throw new Error(`cron edit --exact did not persist: ${JSON.stringify(value.schedule)}`);
|
|
}
|
|
if (value.payload?.kind !== "command" || value.payload.timeoutSeconds !== 30) {
|
|
throw new Error(`cron timeout-only edit changed command payload kind: ${JSON.stringify(value.payload)}`);
|
|
}
|
|
'
|
|
|
|
cron_cli list --all --json > /tmp/cron-cli-list.json
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const jobId = process.argv[1];
|
|
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-list.json", "utf8"));
|
|
if (!Array.isArray(value.jobs) || !value.jobs.some((job) => job.id === jobId && job.name === "cli cron smoke")) {
|
|
throw new Error("created job missing from cron list");
|
|
}
|
|
' "$job_id"
|
|
|
|
cron_cli show "$job_id" --json > /tmp/cron-cli-show.json
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const jobId = process.argv[1];
|
|
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-show.json", "utf8"));
|
|
if (value.id !== jobId || value.name !== "cli cron smoke") {
|
|
throw new Error("cron show returned the wrong job");
|
|
}
|
|
' "$job_id"
|
|
|
|
cron_cli disable "$job_id" > /tmp/cron-cli-disable.json
|
|
cron_cli enable "$job_id" > /tmp/cron-cli-enable.json
|
|
|
|
cron_cli run "$job_id" --wait --wait-timeout 120s --poll-interval 500ms > /tmp/cron-cli-run.json
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-run.json", "utf8"));
|
|
if (value.completed !== true || value.status !== "ok") {
|
|
throw new Error(`cron run did not complete ok: ${JSON.stringify(value)}`);
|
|
}
|
|
'
|
|
|
|
cron_cli runs --id "$job_id" --limit 5 > /tmp/cron-cli-runs.json
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-runs.json", "utf8"));
|
|
const matching = Array.isArray(value.entries)
|
|
? value.entries.find((entry) => entry.status === "ok" && entry.summary === "openclaw-cli-cron-ok")
|
|
: undefined;
|
|
if (!matching) {
|
|
throw new Error("cron runs missing successful command summary");
|
|
}
|
|
'
|
|
|
|
cron_cli rm "$job_id" --json > /tmp/cron-cli-remove.json
|
|
node --input-type=module -e '
|
|
const fs = await import("node:fs/promises");
|
|
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-remove.json", "utf8"));
|
|
if (value.ok !== true) {
|
|
throw new Error("cron remove failed");
|
|
}
|
|
'
|
|
|
|
node --input-type=module -e '
|
|
process.stdout.write(JSON.stringify({ ok: true, jobId: process.argv[1] }) + "\n");
|
|
' "$job_id"
|
|
INNER
|
|
status=${PIPESTATUS[0]}
|
|
set -e
|
|
|
|
if [ "$status" -ne 0 ]; then
|
|
echo "Docker cron CLI smoke failed"
|
|
docker_e2e_print_log "$CLIENT_LOG"
|
|
exit "$status"
|
|
fi
|
|
|
|
docker_e2e_print_log "$CLIENT_LOG"
|
|
echo "OK"
|