Skip to content

Commit b9b90fe

Browse files
committed
Add cron caller identity regression proof
1 parent 5437058 commit b9b90fe

16 files changed

Lines changed: 560 additions & 39 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,6 +1774,7 @@
17741774
"test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh",
17751775
"test:docker:crestodian-planner": "bash scripts/e2e/crestodian-planner-docker.sh",
17761776
"test:docker:crestodian-rescue": "bash scripts/e2e/crestodian-rescue-docker.sh",
1777+
"test:docker:cron-cli": "bash scripts/e2e/cron-cli-docker.sh",
17771778
"test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh",
17781779
"test:docker:codex-media-path": "bash scripts/e2e/codex-media-path-docker.sh",
17791780
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",

scripts/e2e/cron-cli-docker.sh

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env bash
2+
# Starts a packaged Gateway in Docker and verifies public cron CLI CRUD/run flows.
3+
set -euo pipefail
4+
5+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
6+
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
7+
8+
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-cli-e2e" OPENCLAW_IMAGE)"
9+
PORT="18789"
10+
TOKEN="cron-cli-e2e-$(date +%s)-$$"
11+
CONTAINER_NAME="openclaw-cron-cli-e2e-$$"
12+
CLIENT_LOG="$(mktemp -t openclaw-cron-cli-log.XXXXXX)"
13+
14+
cleanup() {
15+
docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
16+
rm -f "$CLIENT_LOG"
17+
}
18+
trap cleanup EXIT
19+
20+
docker_e2e_build_or_reuse "$IMAGE_NAME" cron-cli
21+
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-cli empty)"
22+
23+
echo "Running in-container Gateway + cron CLI smoke..."
24+
set +e
25+
docker_e2e_run_with_harness \
26+
--name "$CONTAINER_NAME" \
27+
-e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \
28+
-e "OPENCLAW_SKIP_CHANNELS=1" \
29+
-e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \
30+
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
31+
-e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \
32+
-e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \
33+
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
34+
-e "GW_TOKEN=$TOKEN" \
35+
-e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \
36+
-i \
37+
"$IMAGE_NAME" \
38+
bash -s >"$CLIENT_LOG" 2>&1 <<'INNER'
39+
set -euo pipefail
40+
41+
source scripts/lib/openclaw-e2e-instance.sh
42+
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
43+
44+
entry="$(openclaw_e2e_resolve_entrypoint)"
45+
gateway_pid=
46+
47+
cleanup_inner() {
48+
openclaw_e2e_stop_process "${gateway_pid:-}"
49+
}
50+
51+
dump_logs_on_error() {
52+
status=$?
53+
if [ "$status" -ne 0 ]; then
54+
openclaw_e2e_dump_logs \
55+
/tmp/cron-cli-gateway.log \
56+
/tmp/cron-cli-device-seed.json \
57+
/tmp/cron-cli-status.json \
58+
/tmp/cron-cli-add.json \
59+
/tmp/cron-cli-list.json \
60+
/tmp/cron-cli-show.json \
61+
/tmp/cron-cli-disable.json \
62+
/tmp/cron-cli-enable.json \
63+
/tmp/cron-cli-run.json \
64+
/tmp/cron-cli-runs.json \
65+
/tmp/cron-cli-remove.json
66+
fi
67+
cleanup_inner
68+
exit "$status"
69+
}
70+
71+
trap cleanup_inner EXIT
72+
trap dump_logs_on_error ERR
73+
74+
cron_cli() {
75+
node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}"
76+
}
77+
78+
seed_paired_cli_device() {
79+
node --input-type=module <<'NODE'
80+
import { readdir, readFile } from "node:fs/promises";
81+
import { join } from "node:path";
82+
import { pathToFileURL } from "node:url";
83+
84+
async function importDistChunk(prefix, marker) {
85+
const distDir = join(process.cwd(), "dist");
86+
const entries = await readdir(distDir);
87+
for (const entry of entries) {
88+
if (!entry.startsWith(prefix) || !entry.endsWith(".js")) {
89+
continue;
90+
}
91+
const fullPath = join(distDir, entry);
92+
if ((await readFile(fullPath, "utf8")).includes(marker)) {
93+
return await import(pathToFileURL(fullPath).href);
94+
}
95+
}
96+
throw new Error(`missing dist chunk ${prefix} containing ${marker}`);
97+
}
98+
99+
const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity");
100+
const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing");
101+
const loadOrCreateDeviceIdentity =
102+
identityModule.loadOrCreateDeviceIdentity ?? identityModule.r;
103+
const publicKeyRawBase64UrlFromPem =
104+
identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a;
105+
const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n;
106+
const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a;
107+
const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m;
108+
109+
if (
110+
typeof loadOrCreateDeviceIdentity !== "function" ||
111+
typeof publicKeyRawBase64UrlFromPem !== "function" ||
112+
typeof approveDevicePairing !== "function" ||
113+
typeof getPairedDevice !== "function" ||
114+
typeof requestDevicePairing !== "function"
115+
) {
116+
throw new Error("missing device pairing exports in dist chunks");
117+
}
118+
119+
const identity = loadOrCreateDeviceIdentity();
120+
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
121+
const requiredScopes = ["operator.admin"];
122+
const paired = await getPairedDevice(identity.deviceId);
123+
const pairedScopes = Array.isArray(paired?.approvedScopes)
124+
? paired.approvedScopes
125+
: Array.isArray(paired?.scopes)
126+
? paired.scopes
127+
: [];
128+
129+
if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) {
130+
const pairing = await requestDevicePairing({
131+
deviceId: identity.deviceId,
132+
publicKey,
133+
displayName: "cron cli docker smoke",
134+
platform: process.platform,
135+
clientId: "cli",
136+
clientMode: "cli",
137+
role: "operator",
138+
scopes: requiredScopes,
139+
silent: true,
140+
});
141+
const approved = await approveDevicePairing(pairing.request.requestId, {
142+
callerScopes: requiredScopes,
143+
});
144+
if (approved?.status !== "approved") {
145+
throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`);
146+
}
147+
}
148+
149+
process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n");
150+
NODE
151+
}
152+
153+
read_json_field() {
154+
local file="$1"
155+
local field="$2"
156+
node --input-type=module -e '
157+
const fs = await import("node:fs/promises");
158+
const [file, field] = process.argv.slice(1);
159+
const value = JSON.parse(await fs.readFile(file, "utf8"))[field];
160+
if (typeof value !== "string" || value.length === 0) {
161+
throw new Error(`missing string field ${field} in ${file}`);
162+
}
163+
process.stdout.write(value);
164+
' "$file" "$field"
165+
}
166+
167+
seed_paired_cli_device > /tmp/cron-cli-device-seed.json
168+
gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)"
169+
openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789
170+
171+
cron_cli status --json > /tmp/cron-cli-status.json
172+
cron_add_args=(
173+
"cli cron smoke"
174+
--every 1m
175+
--command "printf openclaw-cli-cron-ok"
176+
--no-deliver
177+
--timeout-seconds 15
178+
--json
179+
)
180+
cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json
181+
182+
job_id="$(read_json_field /tmp/cron-cli-add.json id)"
183+
184+
cron_cli list --all --json > /tmp/cron-cli-list.json
185+
node --input-type=module -e '
186+
const fs = await import("node:fs/promises");
187+
const jobId = process.argv[1];
188+
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-list.json", "utf8"));
189+
if (!Array.isArray(value.jobs) || !value.jobs.some((job) => job.id === jobId && job.name === "cli cron smoke")) {
190+
throw new Error("created job missing from cron list");
191+
}
192+
' "$job_id"
193+
194+
cron_cli show "$job_id" --json > /tmp/cron-cli-show.json
195+
node --input-type=module -e '
196+
const fs = await import("node:fs/promises");
197+
const jobId = process.argv[1];
198+
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-show.json", "utf8"));
199+
if (value.id !== jobId || value.name !== "cli cron smoke") {
200+
throw new Error("cron show returned the wrong job");
201+
}
202+
' "$job_id"
203+
204+
cron_cli disable "$job_id" > /tmp/cron-cli-disable.json
205+
cron_cli enable "$job_id" > /tmp/cron-cli-enable.json
206+
207+
cron_cli run "$job_id" --wait --wait-timeout 120s --poll-interval 500ms > /tmp/cron-cli-run.json
208+
node --input-type=module -e '
209+
const fs = await import("node:fs/promises");
210+
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-run.json", "utf8"));
211+
if (value.completed !== true || value.status !== "ok") {
212+
throw new Error(`cron run did not complete ok: ${JSON.stringify(value)}`);
213+
}
214+
'
215+
216+
cron_cli runs --id "$job_id" --limit 5 > /tmp/cron-cli-runs.json
217+
node --input-type=module -e '
218+
const fs = await import("node:fs/promises");
219+
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-runs.json", "utf8"));
220+
const matching = Array.isArray(value.entries)
221+
? value.entries.find((entry) => entry.status === "ok" && entry.summary === "openclaw-cli-cron-ok")
222+
: undefined;
223+
if (!matching) {
224+
throw new Error("cron runs missing successful command summary");
225+
}
226+
'
227+
228+
cron_cli rm "$job_id" --json > /tmp/cron-cli-remove.json
229+
node --input-type=module -e '
230+
const fs = await import("node:fs/promises");
231+
const value = JSON.parse(await fs.readFile("/tmp/cron-cli-remove.json", "utf8"));
232+
if (value.ok !== true) {
233+
throw new Error("cron remove failed");
234+
}
235+
'
236+
237+
node --input-type=module -e '
238+
process.stdout.write(JSON.stringify({ ok: true, jobId: process.argv[1] }) + "\n");
239+
' "$job_id"
240+
INNER
241+
status=${PIPESTATUS[0]}
242+
set -e
243+
244+
if [ "$status" -ne 0 ]; then
245+
echo "Docker cron CLI smoke failed"
246+
docker_e2e_print_log "$CLIENT_LOG"
247+
exit "$status"
248+
fi
249+
250+
docker_e2e_print_log "$CLIENT_LOG"
251+
echo "OK"

scripts/e2e/lib/upgrade-survivor/assertions.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs";
77
const command = process.argv[2];
88
const SCENARIOS = new Set([
99
"base",
10+
"acpx-openclaw-tools-bridge",
1011
"feishu-channel",
1112
"bootstrap-persona",
1213
"channel-post-core-restore",
@@ -312,6 +313,16 @@ function assertConfigSurvived() {
312313
}
313314
}
314315

316+
if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) {
317+
const pluginAllow = config.plugins?.allow ?? [];
318+
assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing");
319+
assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed");
320+
assert(
321+
config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true,
322+
"ACPX OpenClaw tools bridge config changed",
323+
);
324+
}
325+
315326
if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) {
316327
const pluginAllow = config.plugins?.allow ?? [];
317328
assert(pluginAllow.includes("discord"), "configured install discord allow entry missing");

scripts/e2e/lib/upgrade-survivor/config-recipe.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ const representativeConfigSteps = [
108108
];
109109

110110
const scenarioConfigSteps = new Map([
111+
[
112+
"acpx-openclaw-tools-bridge",
113+
[
114+
configSetJsonFile(
115+
"plugins-acpx-openclaw-tools-bridge",
116+
"acpx-openclaw-tools-bridge",
117+
"plugins",
118+
"plugins-acpx-openclaw-tools-bridge.json",
119+
),
120+
],
121+
],
111122
[
112123
"feishu-channel",
113124
[
@@ -174,6 +185,15 @@ function selectedScenario() {
174185
}
175186

176187
function adaptStepForBaseline(step, baselineVersion, summary) {
188+
if (
189+
step.intent === "acpx-openclaw-tools-bridge" &&
190+
isReleaseBefore(baselineVersion, "2026.4.22")
191+
) {
192+
if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) {
193+
summary.skippedIntents.push("acpx-openclaw-tools-bridge");
194+
}
195+
return null;
196+
}
177197
if (!isReleaseBefore(baselineVersion, "2026.4.0")) {
178198
return step;
179199
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"enabled": true,
3+
"allow": ["acpx", "discord", "memory", "telegram", "whatsapp"],
4+
"entries": {
5+
"acpx": {
6+
"enabled": true,
7+
"config": {
8+
"openClawToolsMcpBridge": true
9+
}
10+
},
11+
"discord": {
12+
"enabled": true
13+
},
14+
"telegram": {
15+
"enabled": true
16+
},
17+
"whatsapp": {
18+
"enabled": true
19+
}
20+
}
21+
}

scripts/lib/docker-e2e-plan.mjs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function sanitizeLaneNameSuffix(value) {
7575

7676
const UPGRADE_SURVIVOR_SCENARIOS = [
7777
"base",
78+
"acpx-openclaw-tools-bridge",
7879
"feishu-channel",
7980
"bootstrap-persona",
8081
"channel-post-core-restore",
@@ -183,6 +184,17 @@ function supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec) {
183184
return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 23 }) >= 0;
184185
}
185186

187+
function supportsUpgradeSurvivorAcpToolsBridge(baselineSpec) {
188+
if (!baselineSpec) {
189+
return true;
190+
}
191+
const version = parsePublishedReleaseVersion(baselineSpec);
192+
if (!version) {
193+
return true;
194+
}
195+
return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 22 }) >= 0;
196+
}
197+
186198
function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios = "") {
187199
const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs);
188200
const scenarios = parseUpgradeSurvivorScenarios(rawScenarios);
@@ -199,8 +211,10 @@ function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScen
199211
matrixScenarios
200212
.filter(
201213
(scenario) =>
202-
scenario !== "plugin-deps-cleanup" ||
203-
supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec),
214+
(scenario !== "plugin-deps-cleanup" ||
215+
supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec)) &&
216+
(scenario !== "acpx-openclaw-tools-bridge" ||
217+
supportsUpgradeSurvivorAcpToolsBridge(baselineSpec)),
204218
)
205219
.map((scenario) => {
206220
const suffixParts = [

scripts/test-live-acp-bind-docker.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
422422
-e OPENCLAW_LIVE_TEST=1 \
423423
-e OPENCLAW_LIVE_ACP_BIND=1 \
424424
-e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \
425+
-e OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON="${OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON:-}" \
425426
-e OPENCLAW_LIVE_ACP_BIND_TEST_FILES="${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-}" \
426427
-e OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL="${OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL:-}" \
427428
-e OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS="$ACP_SETUP_TIMEOUT_SECONDS" \

0 commit comments

Comments
 (0)