mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 00:49:30 +00:00
fix(security): ignore Docker rerun artifact commands
This commit is contained in:
@@ -92,6 +92,55 @@ function maybeGhcrImage(value) {
|
||||
return typeof value === "string" && value.startsWith("ghcr.io/") ? value : "";
|
||||
}
|
||||
|
||||
const TRUSTED_WORKFLOW_INPUTS = new Map([
|
||||
["package_artifact_run_id", "packageArtifactRunId"],
|
||||
["package_artifact_name", "packageArtifactName"],
|
||||
["docker_e2e_bare_image", "bareImage"],
|
||||
["docker_e2e_functional_image", "functionalImage"],
|
||||
["published_upgrade_survivor_baseline", "publishedUpgradeSurvivorBaseline"],
|
||||
["published_upgrade_survivor_baselines", "publishedUpgradeSurvivorBaselines"],
|
||||
["published_upgrade_survivor_scenarios", "publishedUpgradeSurvivorScenarios"],
|
||||
]);
|
||||
|
||||
const REUSE_INPUT_KEYS = [
|
||||
"packageArtifactRunId",
|
||||
"packageArtifactName",
|
||||
"bareImage",
|
||||
"functionalImage",
|
||||
"workflowRef",
|
||||
"publishedUpgradeSurvivorBaseline",
|
||||
"publishedUpgradeSurvivorBaselines",
|
||||
"publishedUpgradeSurvivorScenarios",
|
||||
];
|
||||
|
||||
const WORKFLOW_INPUT_RE = /(?:^|\s)-f\s+([a-z0-9_]+)=('([^']*)'|[^\s]+)/gu;
|
||||
const WORKFLOW_REF_RE = /(?:^|\s)--ref\s+('([^']*)'|[^\s]+)/u;
|
||||
|
||||
function trustedReuseInputsFromCommand(command) {
|
||||
const text = String(command ?? "");
|
||||
if (!/^\s*gh\s+workflow\s+run\s/u.test(text)) {
|
||||
return {};
|
||||
}
|
||||
const inputs = {};
|
||||
const refValue = text.match(WORKFLOW_REF_RE);
|
||||
if (refValue) {
|
||||
inputs.workflowRef = (refValue[2] ?? refValue[1] ?? "").replace(/^'/u, "").replace(/'$/u, "");
|
||||
}
|
||||
for (const match of text.matchAll(WORKFLOW_INPUT_RE)) {
|
||||
const target = TRUSTED_WORKFLOW_INPUTS.get(match[1]);
|
||||
const value = (match[3] ?? match[2] ?? "").replace(/^'/u, "").replace(/'$/u, "");
|
||||
if (!target || !value) {
|
||||
continue;
|
||||
}
|
||||
const normalized =
|
||||
target === "bareImage" || target === "functionalImage" ? maybeGhcrImage(value) : value;
|
||||
if (normalized) {
|
||||
inputs[target] = normalized;
|
||||
}
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function reuseInputsFromJson(parsed) {
|
||||
const packageArtifactRunId = parsed.github?.runId || "";
|
||||
if (!packageArtifactRunId) {
|
||||
@@ -107,12 +156,11 @@ function reuseInputsFromJson(parsed) {
|
||||
}
|
||||
|
||||
function sameReuseInputs(left, right) {
|
||||
return (
|
||||
(left?.packageArtifactRunId || "") === (right?.packageArtifactRunId || "") &&
|
||||
(left?.packageArtifactName || "") === (right?.packageArtifactName || "") &&
|
||||
(left?.bareImage || "") === (right?.bareImage || "") &&
|
||||
(left?.functionalImage || "") === (right?.functionalImage || "")
|
||||
);
|
||||
return REUSE_INPUT_KEYS.every((key) => (left?.[key] || "") === (right?.[key] || ""));
|
||||
}
|
||||
|
||||
function reuseInputsKey(inputs) {
|
||||
return JSON.stringify(REUSE_INPUT_KEYS.map((key) => inputs?.[key] || ""));
|
||||
}
|
||||
|
||||
function commonReuseInputs(entries) {
|
||||
@@ -124,8 +172,25 @@ function commonReuseInputs(entries) {
|
||||
return inputs.every((input) => sameReuseInputs(first, input)) ? first : {};
|
||||
}
|
||||
|
||||
function groupByReuseInputs(entries) {
|
||||
const groups = new Map();
|
||||
for (const entry of entries) {
|
||||
const key = reuseInputsKey(entry.reuseInputs);
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.push(entry);
|
||||
} else {
|
||||
groups.set(key, [entry]);
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
|
||||
const workflowRef = process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF || process.env.GITHUB_REF_NAME;
|
||||
const workflowRef =
|
||||
reuseInputs.workflowRef ||
|
||||
process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF ||
|
||||
process.env.GITHUB_REF_NAME;
|
||||
const releasePath = lanes.some(laneNeedsReleasePath);
|
||||
const fields = [
|
||||
"gh workflow run",
|
||||
@@ -159,6 +224,30 @@ function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
|
||||
if (reuseInputs.functionalImage) {
|
||||
fields.push("-f", `docker_e2e_functional_image=${shellQuote(reuseInputs.functionalImage)}`);
|
||||
}
|
||||
if (reuseInputs.publishedUpgradeSurvivorBaseline) {
|
||||
fields.push(
|
||||
"-f",
|
||||
`published_upgrade_survivor_baseline=${shellQuote(
|
||||
reuseInputs.publishedUpgradeSurvivorBaseline,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (reuseInputs.publishedUpgradeSurvivorBaselines) {
|
||||
fields.push(
|
||||
"-f",
|
||||
`published_upgrade_survivor_baselines=${shellQuote(
|
||||
reuseInputs.publishedUpgradeSurvivorBaselines,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (reuseInputs.publishedUpgradeSurvivorScenarios) {
|
||||
fields.push(
|
||||
"-f",
|
||||
`published_upgrade_survivor_scenarios=${shellQuote(
|
||||
reuseInputs.publishedUpgradeSurvivorScenarios,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return fields.join(" ");
|
||||
}
|
||||
|
||||
@@ -169,20 +258,31 @@ function failureName(failure) {
|
||||
function failedEntryFromRecord(failure, file, ref, workflow, reuseInputs) {
|
||||
const lane = failureName(failure);
|
||||
const targetable = failure.targetable !== false;
|
||||
const workflowInputs = {
|
||||
...trustedReuseInputsFromCommand(failure.ghWorkflowCommand),
|
||||
...reuseInputs,
|
||||
};
|
||||
return {
|
||||
ghWorkflowCommand: targetable
|
||||
? failure.ghWorkflowCommand || ghWorkflowCommand([lane], ref, workflow, reuseInputs)
|
||||
: "",
|
||||
lane,
|
||||
localRerunCommand: failure.rerunCommand,
|
||||
logFile: failure.logFile,
|
||||
reuseInputs,
|
||||
reuseInputs: workflowInputs,
|
||||
source: file,
|
||||
status: failure.status,
|
||||
targetable,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReuseInputs(left = {}, right = {}) {
|
||||
const merged = { ...left };
|
||||
for (const [key, value] of Object.entries(right)) {
|
||||
if (value) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function detectRepo() {
|
||||
return run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).trim();
|
||||
}
|
||||
@@ -222,7 +322,18 @@ function failedLaneEntriesFromJson(file, ref, workflow) {
|
||||
function mergeByLane(entries) {
|
||||
const byLane = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byLane.has(entry.lane)) {
|
||||
const existing = byLane.get(entry.lane);
|
||||
if (existing) {
|
||||
byLane.set(entry.lane, {
|
||||
...existing,
|
||||
...entry,
|
||||
localRerunCommand: existing.localRerunCommand || entry.localRerunCommand,
|
||||
logFile: existing.logFile || entry.logFile,
|
||||
reuseInputs: mergeReuseInputs(existing.reuseInputs, entry.reuseInputs),
|
||||
source: existing.source || entry.source,
|
||||
targetable: existing.targetable !== false && entry.targetable !== false,
|
||||
});
|
||||
} else {
|
||||
byLane.set(entry.lane, entry);
|
||||
}
|
||||
}
|
||||
@@ -288,22 +399,33 @@ function printEntries(entries, ref, workflow, runValue) {
|
||||
console.log(`Failed Docker E2E entries: ${entries.map((entry) => entry.lane).join(", ")}`);
|
||||
if (workflowEntries.length > 0) {
|
||||
console.log("");
|
||||
console.log("Combined GitHub rerun:");
|
||||
console.log(
|
||||
ghWorkflowCommand(
|
||||
workflowEntries.map((entry) => entry.lane),
|
||||
ref,
|
||||
workflow,
|
||||
commonReuseInputs(workflowEntries),
|
||||
),
|
||||
);
|
||||
console.log("");
|
||||
console.log("Per-lane GitHub reruns:");
|
||||
for (const entry of workflowEntries) {
|
||||
const workflowGroups = groupByReuseInputs(workflowEntries);
|
||||
if (workflowGroups.length === 1) {
|
||||
console.log("Combined GitHub rerun:");
|
||||
console.log(
|
||||
`- ${entry.lane}: ${entry.ghWorkflowCommand || ghWorkflowCommand([entry.lane], ref, workflow)}`,
|
||||
ghWorkflowCommand(
|
||||
workflowEntries.map((entry) => entry.lane),
|
||||
ref,
|
||||
workflow,
|
||||
commonReuseInputs(workflowEntries),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log("Combined GitHub reruns:");
|
||||
for (const group of workflowGroups) {
|
||||
const lanes = group.map((entry) => entry.lane);
|
||||
console.log(
|
||||
`- ${lanes.join(", ")}: ${ghWorkflowCommand(lanes, ref, workflow, group[0]?.reuseInputs)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
console.log("Per-lane GitHub reruns:");
|
||||
for (const entry of workflowEntries) {
|
||||
console.log(
|
||||
`- ${entry.lane}: ${ghWorkflowCommand([entry.lane], ref, workflow, entry.reuseInputs)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("");
|
||||
console.log("No targetable failed Docker E2E lanes found.");
|
||||
|
||||
@@ -187,4 +187,193 @@ describe("Docker E2E helper CLIs", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores artifact-provided GitHub rerun commands", () => {
|
||||
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-command-`);
|
||||
try {
|
||||
const file = path.join(root, "failures.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lanes: [
|
||||
{
|
||||
ghWorkflowCommand: "echo poisoned-command",
|
||||
name: "gateway-network",
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: "failed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("docker_lanes='gateway-network'");
|
||||
expect(result.stdout).not.toContain("poisoned-command");
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves whitelisted rerun inputs from artifact commands", () => {
|
||||
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-inputs-`);
|
||||
try {
|
||||
const file = path.join(root, "failures.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lanes: [
|
||||
{
|
||||
ghWorkflowCommand:
|
||||
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f unsafe_input='do-not-copy'",
|
||||
name: "published-upgrade-survivor-openclaw-2026-5-3",
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: "failed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? "";
|
||||
expect(combinedCommand).toContain("--ref 'release/2026.6'");
|
||||
expect(combinedCommand).toContain("package_artifact_run_id='12345'");
|
||||
expect(combinedCommand).toContain(
|
||||
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'",
|
||||
);
|
||||
expect(combinedCommand).toContain(
|
||||
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
|
||||
);
|
||||
expect(combinedCommand).toContain(
|
||||
"published_upgrade_survivor_scenarios='plugin-dependency-cleanup'",
|
||||
);
|
||||
expect(combinedCommand).not.toContain("unsafe_input");
|
||||
expect(result.stdout).toContain("package_artifact_run_id='12345'");
|
||||
expect(result.stdout).toContain(
|
||||
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'",
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
"published_upgrade_survivor_scenarios='plugin-dependency-cleanup'",
|
||||
);
|
||||
expect(result.stdout).not.toContain("unsafe_input");
|
||||
expect(result.stdout).not.toContain("do-not-copy");
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("groups combined reruns by recovered workflow inputs", () => {
|
||||
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-groups-`);
|
||||
try {
|
||||
const file = path.join(root, "failures.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lanes: [
|
||||
{
|
||||
ghWorkflowCommand:
|
||||
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'",
|
||||
name: "published-upgrade-survivor-openclaw-2026-5-3",
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
ghWorkflowCommand:
|
||||
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.2'",
|
||||
name: "published-upgrade-survivor-openclaw-2026-5-2",
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: "failed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("Combined GitHub reruns:");
|
||||
expect(result.stdout).toContain(
|
||||
"- published-upgrade-survivor-openclaw-2026-5-3: gh workflow run",
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
"- published-upgrade-survivor-openclaw-2026-5-2: gh workflow run",
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
"docker_lanes='published-upgrade-survivor-openclaw-2026-5-3'",
|
||||
);
|
||||
expect(result.stdout).toContain(
|
||||
"docker_lanes='published-upgrade-survivor-openclaw-2026-5-2'",
|
||||
);
|
||||
expect(result.stdout).not.toContain(
|
||||
"docker_lanes='published-upgrade-survivor-openclaw-2026-5-3 published-upgrade-survivor-openclaw-2026-5-2'",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges duplicate lane entries before printing reruns", () => {
|
||||
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-merge-`);
|
||||
try {
|
||||
const file = path.join(root, "failures.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
lanes: [
|
||||
{
|
||||
name: "published-upgrade-survivor-openclaw-2026-5-3",
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
ghWorkflowCommand:
|
||||
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'",
|
||||
name: "published-upgrade-survivor-openclaw-2026-5-3",
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
status: "failed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? "";
|
||||
expect(combinedCommand).toContain("--ref 'release/2026.6'");
|
||||
expect(combinedCommand).toContain(
|
||||
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user