Compare commits

..

5 Commits

Author SHA1 Message Date
Dallin Romney
764fc3916e test: probe dependency guard with script change 2026-05-28 18:30:48 -07:00
Dallin Romney
1c33856130 ci: rename dependency guard workflow 2026-05-28 18:23:52 -07:00
Peter Steinberger
c0d525c8a0 fix: validate whatsapp login timeout 2026-05-28 21:18:29 -04:00
Vincent Koc
c66c404d58 fix(e2e): reject loose pty env limits 2026-05-29 03:17:12 +02:00
Peter Steinberger
10a3417bd3 fix: validate browser act numeric params 2026-05-28 21:16:12 -04:00
14 changed files with 158 additions and 58 deletions

8
.github/CODEOWNERS vendored
View File

@@ -11,10 +11,10 @@
/.github/workflows/codeql.yml @openclaw/openclaw-secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-change-awareness-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-change-awareness.mjs @openclaw/openclaw-secops
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
/package-lock.json @openclaw/openclaw-secops
/npm-shrinkwrap.json @openclaw/openclaw-secops
/extensions/*/package-lock.json @openclaw/openclaw-secops

View File

@@ -1,4 +1,4 @@
name: Dependency Change Awareness
name: Dependency Guard
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
@@ -10,11 +10,11 @@ permissions:
issues: write
concurrency:
group: dependency-change-awareness-${{ github.event.pull_request.number }}
group: dependency-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dependency-change-awareness:
dependency-guard:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -30,4 +30,4 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/dependency-change-awareness.mjs
run: node scripts/github/dependency-guard.mjs

View File

@@ -40,9 +40,15 @@ type BrowserActRequest = Parameters<typeof browserAct>[1];
type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number };
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
return readPositiveIntegerParam({ value }, "value", {
message: "timeoutMs must be a positive integer.",
});
}
function normalizeNonNegativeDurationMs(value: unknown): number | undefined {
return readNonNegativeIntegerParam({ value }, "value", {
message: "timeMs must be a non-negative integer.",
});
}
function supportsBrowserActTimeout(request: BrowserActRequest): boolean {
@@ -114,7 +120,7 @@ function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefine
candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}
if (request.kind === "wait") {
const waitDuration = normalizePositiveTimeoutMs(request.timeMs);
const waitDuration = normalizeNonNegativeDurationMs(request.timeMs);
if (waitDuration !== undefined) {
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
}

View File

@@ -1,4 +1,5 @@
import {
optionalFiniteNumberSchema,
optionalNonNegativeIntegerSchema,
optionalPositiveIntegerSchema,
optionalStringEnum,
@@ -61,15 +62,15 @@ const BrowserActSchema = Type.Object({
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
x: optionalFiniteNumberSchema(),
y: optionalFiniteNumberSchema(),
// type
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
// press
key: Type.Optional(Type.String()),
delayMs: Type.Optional(Type.Number()),
delayMs: optionalNonNegativeIntegerSchema(),
// drag
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
@@ -78,10 +79,10 @@ const BrowserActSchema = Type.Object({
// fill - use permissive array of objects
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
// resize
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
width: optionalPositiveIntegerSchema(),
height: optionalPositiveIntegerSchema(),
// wait
timeMs: Type.Optional(Type.Number()),
timeMs: optionalNonNegativeIntegerSchema(),
selector: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
loadState: Type.Optional(Type.String()),
@@ -131,20 +132,20 @@ export const BrowserToolSchema = Type.Object({
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
x: optionalFiniteNumberSchema(),
y: optionalFiniteNumberSchema(),
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
key: Type.Optional(Type.String()),
delayMs: Type.Optional(Type.Number()),
delayMs: optionalNonNegativeIntegerSchema(),
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
values: Type.Optional(Type.Array(Type.String())),
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
timeMs: Type.Optional(Type.Number()),
width: optionalPositiveIntegerSchema(),
height: optionalPositiveIntegerSchema(),
timeMs: optionalNonNegativeIntegerSchema(),
textGone: Type.Optional(Type.String()),
loadState: Type.Optional(Type.String()),
fn: Type.Optional(Type.String()),

View File

@@ -1372,6 +1372,40 @@ describe("browser tool act compatibility", () => {
expect(request.params?.body).toEqual({ kind: "wait", timeMs: 20_000, timeoutMs: 45_000 });
expect(request.params?.timeoutMs).toBe(45_000 + 5_000);
});
it("honors string act request timeouts when sizing node proxy calls", async () => {
mockSingleBrowserProxyNode();
const tool = createBrowserTool();
await tool.execute?.("call-1", {
action: "act",
target: "node",
request: { kind: "wait", timeMs: "20000", timeoutMs: "45000" },
});
const { options, request } = lastNodeInvokeCall();
expect(options.timeoutMs).toBe(55_000);
expect(request.params?.path).toBe("/act");
expect(request.params?.body).toEqual({
kind: "wait",
timeMs: "20000",
timeoutMs: "45000",
});
expect(request.params?.timeoutMs).toBe(50_000);
});
it("rejects fractional act request timeouts before node proxy calls", async () => {
mockSingleBrowserProxyNode();
const tool = createBrowserTool();
await expect(
tool.execute?.("call-1", {
action: "act",
target: "node",
request: { kind: "wait", timeMs: "20000", timeoutMs: "45000.5" },
}),
).rejects.toThrow("timeoutMs must be a positive integer.");
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
});
describe("browser tool snapshot labels", () => {

View File

@@ -26,7 +26,7 @@ describe("createWhatsAppLoginTool", () => {
const tool = createWhatsAppLoginTool();
const result = await tool.execute("tool-call-1", {
action: "wait",
timeoutMs: 5000,
timeoutMs: "5000",
accountId,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
@@ -56,6 +56,39 @@ describe("createWhatsAppLoginTool", () => {
});
});
it("passes string timeoutMs through to start actions", async () => {
startWebLoginWithQrMock.mockResolvedValueOnce({
connected: false,
message: "Scan this QR in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,current-qr",
});
const tool = createWhatsAppLoginTool();
await tool.execute("tool-call-start", {
action: "start",
timeoutMs: "6000",
accountId: "account-3",
});
expect(startWebLoginWithQrMock).toHaveBeenCalledWith({
accountId: "account-3",
timeoutMs: 6000,
force: false,
});
});
it("rejects fractional timeoutMs before login actions", async () => {
const tool = createWhatsAppLoginTool();
await expect(
tool.execute("tool-call-start", {
action: "start",
timeoutMs: "6000.5",
}),
).rejects.toThrow("timeoutMs must be a positive integer");
expect(startWebLoginWithQrMock).not.toHaveBeenCalled();
});
it("does not retain QR state across tool actions", async () => {
const accountId = "account-2";
startWebLoginWithQrMock.mockResolvedValueOnce({

View File

@@ -1,3 +1,7 @@
import {
optionalPositiveIntegerSchema,
readPositiveIntegerParam,
} from "openclaw/plugin-sdk/channel-actions";
import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract";
import { Type } from "typebox";
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
@@ -20,7 +24,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
type: "string",
enum: ["start", "wait"],
}),
timeoutMs: Type.Optional(Type.Number()),
timeoutMs: optionalPositiveIntegerSchema(),
force: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String()),
currentQrDataUrl: Type.Optional(
@@ -54,13 +58,11 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
const action = (args as { action?: string })?.action ?? "start";
const accountId = readOptionalString((args as { accountId?: unknown }).accountId);
const timeoutMs = readPositiveIntegerParam(args as Record<string, unknown>, "timeoutMs");
if (action === "wait") {
const result = await waitForWebLogin({
accountId,
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
timeoutMs,
currentQrDataUrl: readOptionalString(
(args as { currentQrDataUrl?: unknown }).currentQrDataUrl,
),
@@ -80,10 +82,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
const result = await startWebLoginWithQr({
accountId,
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
timeoutMs,
force:
typeof (args as { force?: unknown }).force === "boolean"
? (args as { force?: boolean }).force

View File

@@ -1404,6 +1404,7 @@
"canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs",
"changed:lanes": "node scripts/changed-lanes.mjs",
"check": "node scripts/check.mjs",
"check:dependency-guard-script-probe": "node -e \"process.exit(0)\"",
"check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles && pnpm check:deprecated-api-usage && pnpm check:deprecated-jsdoc",
"check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check",
"check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check",
@@ -1908,7 +1909,7 @@
"tsdown": "0.22.0",
"tsx": "4.22.3",
"unrun": "0.3.0",
"vitest": "4.1.8"
"vitest": "4.1.7"
},
"optionalDependencies": {
"sharp": "0.34.5",

1
pnpm-lock.yaml generated
View File

@@ -1,4 +1,3 @@
# Dependency guard probe: intentional throw-away devDependency lockfile diff.
lockfileVersion: '9.0'
settings:

View File

@@ -2,6 +2,7 @@
import fs from "node:fs";
import process from "node:process";
import { spawn } from "@lydell/node-pty";
import { readPositiveIntEnv } from "./env-limits.mjs";
const [logPath, command, ...args] = process.argv.slice(2);
@@ -13,8 +14,8 @@ if (!logPath || !command) {
const log = fs.createWriteStream(logPath, { flags: "w" });
const pty = spawn(command, args, {
name: process.env.TERM || "xterm-256color",
cols: Number(process.env.COLUMNS || 120),
rows: Number(process.env.LINES || 40),
cols: readPositiveIntEnv("COLUMNS", 120),
rows: readPositiveIntEnv("LINES", 40),
cwd: process.cwd(),
env: process.env,
});

View File

@@ -2,7 +2,7 @@
import { appendFile, readFile } from "node:fs/promises";
export const dependencyChangeMarker = "<!-- openclaw:dependency-change-awareness -->";
export const dependencyChangeMarker = "<!-- openclaw:dependency-guard -->";
export const dependencyGraphGuardMarker = "<!-- openclaw:dependency-graph-guard -->";
export const dependencyChangedLabel = "dependencies-changed";
export const allowDependenciesCommand = "/allow-dependencies-change";
@@ -316,7 +316,7 @@ function githubApi(token) {
const baseHeaders = {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "openclaw-dependency-change-awareness",
"user-agent": "openclaw-dependency-guard",
"x-github-api-version": "2022-11-28",
};
const request = async (path, options = {}) => {
@@ -521,7 +521,7 @@ async function main() {
renderClearedDependencyGuardComment({ headSha: pullRequest.head?.sha }),
);
}
await writeSummary("## Dependency Change Awareness\n\nNo dependency-related file changes detected.");
await writeSummary("## Dependency Guard\n\nNo dependency-related file changes detected.");
console.log("No dependency-related file changes detected.");
return;
}
@@ -533,7 +533,7 @@ async function main() {
);
await writeSummary(
[
"## Dependency Change Awareness",
"## Dependency Guard",
"",
`Detected ${dependencyGraphFiles.length} dependency-related file change(s).`,
"",

View File

@@ -15,12 +15,12 @@ import {
renderClearedDependencyGuardComment,
sanitizeDisplayValue,
securityApproverSet,
} from "../../scripts/github/dependency-change-awareness.mjs";
} from "../../scripts/github/dependency-guard.mjs";
const headSha = "a".repeat(40);
const staleSha = "b".repeat(40);
describe("dependency change awareness script", () => {
describe("dependency guard script", () => {
it("detects dependency awareness file surfaces", () => {
expect(isDependencyFile("pnpm-lock.yaml")).toBe(true);
expect(isDependencyFile("package.json")).toBe(false);

View File

@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const WORKFLOW = ".github/workflows/dependency-change-awareness.yml";
const WORKFLOW = ".github/workflows/dependency-guard.yml";
const CODEOWNERS = ".github/CODEOWNERS";
type WorkflowStep = {
@@ -13,11 +13,13 @@ type WorkflowStep = {
};
type WorkflowJob = {
name?: string;
steps?: WorkflowStep[];
};
type Workflow = {
jobs?: Record<string, WorkflowJob>;
name?: string;
permissions?: Record<string, string>;
};
@@ -25,7 +27,15 @@ function readWorkflow(): Workflow {
return parse(readFileSync(WORKFLOW, "utf8")) as Workflow;
}
describe("dependency change awareness workflow", () => {
describe("dependency guard workflow", () => {
it("uses the dependency guard check name", () => {
const parsed = readWorkflow();
expect(parsed.name).toBe("Dependency Guard");
expect(parsed.jobs).toHaveProperty("dependency-guard");
expect(parsed.jobs?.["dependency-guard"]?.name).toBeUndefined();
});
it("uses a metadata-only pull_request_target workflow with minimal write permissions", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const parsed = readWorkflow();
@@ -57,29 +67,29 @@ describe("dependency change awareness workflow", () => {
expect(workflow).not.toContain(snippet);
}
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
const steps = readWorkflow().jobs?.["dependency-guard"]?.steps ?? [];
expect(steps).toHaveLength(2);
expect(steps[0].uses).toBe("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd");
expect(steps[0].with?.ref).toBe("${{ github.event.pull_request.base.sha }}");
expect(steps[0].with?.["persist-credentials"]).toBe(false);
expect(steps[1].run).toBe("node scripts/github/dependency-change-awareness.mjs");
expect(steps[1].run).toBe("node scripts/github/dependency-guard.mjs");
});
it("uses a dedicated checked-in script and bounded sticky comments", () => {
const workflow = readFileSync(WORKFLOW, "utf8");
const steps = readWorkflow().jobs?.["dependency-change-awareness"]?.steps ?? [];
const steps = readWorkflow().jobs?.["dependency-guard"]?.steps ?? [];
const runStep = steps[1];
const script = readFileSync("scripts/github/dependency-change-awareness.mjs", "utf8");
const script = readFileSync("scripts/github/dependency-guard.mjs", "utf8");
expect(runStep.env?.OPENCLAW_SECURITY_TEAM_SLUG).toBe("openclaw-secops");
expect(runStep.env?.OPENCLAW_SECURITY_APPROVERS).toBe("vincentkoc,steipete,joshavant");
expect(workflow).toContain("scripts/github/dependency-change-awareness.mjs");
expect(workflow).toContain("scripts/github/dependency-guard.mjs");
expect(script).toContain('"dependencies-changed"');
expect(script).not.toContain('"blocked: dependencies"');
});
it("detects the intended dependency-related file surfaces", () => {
const script = readFileSync("scripts/github/dependency-change-awareness.mjs", "utf8");
const script = readFileSync("scripts/github/dependency-guard.mjs", "utf8");
expect(script).toContain('filename.endsWith("package.json")');
expect(script).toContain('filename.endsWith("package-lock.json")');
expect(script).toContain('filename.endsWith("npm-shrinkwrap.json")');
@@ -90,7 +100,7 @@ describe("dependency change awareness workflow", () => {
});
it("blocks package lockfile and manifest graph changes unless secops approves the current head sha", () => {
const script = readFileSync("scripts/github/dependency-change-awareness.mjs", "utf8");
const script = readFileSync("scripts/github/dependency-guard.mjs", "utf8");
expect(script).toContain('filename.endsWith("pnpm-lock.yaml")');
expect(script).toContain('filename.endsWith("package-lock.json")');
expect(script).toContain('filename.endsWith("npm-shrinkwrap.json")');
@@ -110,13 +120,13 @@ describe("dependency change awareness workflow", () => {
it("requires secops review for future workflow or guard changes", () => {
const codeowners = readFileSync(CODEOWNERS, "utf8");
expect(codeowners).toContain(
"/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops",
"/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops",
);
expect(codeowners).toContain(
"/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops",
"/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops",
);
expect(codeowners).toContain(
"/scripts/github/dependency-change-awareness.mjs @openclaw/openclaw-secops",
"/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops",
);
expect(codeowners).toContain("/package-lock.json @openclaw/openclaw-secops");
expect(codeowners).toContain("/npm-shrinkwrap.json @openclaw/openclaw-secops");

View File

@@ -8,7 +8,10 @@ import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const scriptPath = path.join(repoRoot, "scripts/e2e/lib/run-with-pty.mjs");
function runPtyProbe(logPath: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
function runPtyProbe(
logPath: string,
env: Record<string, string> = {},
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
@@ -19,7 +22,7 @@ function runPtyProbe(logPath: string): Promise<{ code: number | null; stdout: st
"-lc",
'printf "prompt\\n"; IFS= read -r value; printf "got:%s\\n" "$value"',
],
{ stdio: ["pipe", "pipe", "pipe"] },
{ env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"] },
);
let stdout = "";
let stderr = "";
@@ -49,6 +52,19 @@ function runPtyProbe(logPath: string): Promise<{ code: number | null; stdout: st
}
describe("run-with-pty", () => {
it("rejects loose terminal dimension env values", async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-run-with-pty-"));
const logPath = path.join(tempRoot, "pty.log");
try {
const result = await runPtyProbe(logPath, { COLUMNS: "120cols" });
expect(result.code).not.toBe(0);
expect(result.stderr).toContain("invalid COLUMNS: 120cols");
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
it("forwards stdin through a PTY and writes the transcript log", async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-run-with-pty-"));
const logPath = path.join(tempRoot, "pty.log");