Files
openclaw/scripts/e2e/cron-cli-docker.sh
Josh Avant cee2aca409 Scope agent cron operations to the calling agent (#96883)
* Scope agent cron operations to caller

* Scope OpenClaw tools MCP cron by session

* Address cron scope review feedback

* Preserve unscoped cron update retargeting

* Move cron caller identity into gateway context

* Clarify Gateway restart guidance

* Add cron caller identity regression proof
2026-06-26 21:41:14 -05:00

252 lines
8.2 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-device-seed.json \
/tmp/cron-cli-status.json \
/tmp/cron-cli-add.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}"
}
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";
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);
}
}
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
}
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");
}
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"}`);
}
}
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
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"
}
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
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
cron_cli status --json > /tmp/cron-cli-status.json
cron_add_args=(
"cli cron smoke"
--every 1m
--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 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"