diff --git a/scripts/dev/discord-acp-plain-language-smoke.ts b/scripts/dev/discord-acp-plain-language-smoke.ts index 871a583bb8ea..5f0c4c082de5 100644 --- a/scripts/dev/discord-acp-plain-language-smoke.ts +++ b/scripts/dev/discord-acp-plain-language-smoke.ts @@ -136,6 +136,26 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_OPENCLAW_CLI_TIMEOUT_MS = 60_000; const DISCORD_RESPONSE_BODY_MAX_BYTES = 1024 * 1024; const WEBHOOK_CLEANUP_TIMEOUT_MS = 10_000; +const BOOLEAN_OPTIONS = new Set(["--help", "-h", "--json"]); +const VALUE_OPTIONS = new Set([ + "--channel", + "--driver", + "--token", + "--token-prefix", + "--bot-token", + "--bot-token-prefix", + "--agent", + "--mention", + "--instruction", + "--timeout-ms", + "--poll-ms", + "--state-dir", + "--openclaw-bin", +]); + +class CliArgumentError extends Error { + override name = "CliArgumentError"; +} function sleep(ms: number): Promise { return new Promise((resolve) => { @@ -237,21 +257,48 @@ function resolveStateDir(): string { return path.join(home, ".openclaw"); } -function resolveArg(flag: string): string | undefined { - const argv = process.argv.slice(2); +function resolveArg(flag: string, argv: string[]): string | undefined { const eq = argv.find((entry) => entry.startsWith(`${flag}=`)); if (eq) { - return eq.slice(flag.length + 1); + const value = eq.slice(flag.length + 1); + if (!value) { + throw new CliArgumentError(`${flag} requires a value`); + } + return value; } const idx = argv.indexOf(flag); - if (idx >= 0 && idx + 1 < argv.length) { - return argv[idx + 1]; + if (idx < 0) { + return undefined; } - return undefined; + const value = argv[idx + 1]; + if (!value || value.startsWith("--")) { + throw new CliArgumentError(`${flag} requires a value`); + } + return value; } -function hasFlag(flag: string): boolean { - return process.argv.slice(2).includes(flag); +function hasFlag(flag: string, argv = process.argv.slice(2)): boolean { + return argv.includes(flag); +} + +function validateCliArgs(argv: string[]): void { + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] ?? ""; + if (BOOLEAN_OPTIONS.has(arg)) { + continue; + } + if (arg.startsWith("--") && arg.includes("=")) { + const [flag] = arg.split("=", 1); + if (VALUE_OPTIONS.has(flag)) { + continue; + } + } + if (VALUE_OPTIONS.has(arg)) { + index += 1; + continue; + } + throw new CliArgumentError(`Unknown argument: ${arg}`); + } } function parseDriverMode(raw: string): DriverMode { @@ -314,43 +361,54 @@ function usage(): string { ); } -function parseArgs(): Args { - const channelId = resolveArg("--channel") || process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID || ""; +function parseArgs(argv = process.argv.slice(2)): Args { + validateCliArgs(argv); + const channelId = + resolveArg("--channel", argv) || process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID || ""; const driverModeRaw = - resolveArg("--driver") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER || "token"; + resolveArg("--driver", argv) || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER || "token"; const driverMode = parseDriverMode(driverModeRaw); const driverToken = - resolveArg("--token") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN || ""; + resolveArg("--token", argv) || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN || ""; const driverTokenPrefix = - resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot"; + resolveArg("--token-prefix", argv) || + process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || + "Bot"; const botToken = - resolveArg("--bot-token") || + resolveArg("--bot-token", argv) || process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN || ""; const botTokenPrefix = - resolveArg("--bot-token-prefix") || + resolveArg("--bot-token-prefix", argv) || process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX || "Bot"; - const targetAgent = resolveArg("--agent") || process.env.OPENCLAW_DISCORD_SMOKE_AGENT || "codex"; + const targetAgent = + resolveArg("--agent", argv) || process.env.OPENCLAW_DISCORD_SMOKE_AGENT || "codex"; const mentionUserId = - resolveArg("--mention") || process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID || undefined; + resolveArg("--mention", argv) || + process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID || + undefined; const instruction = - resolveArg("--instruction") || process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION || undefined; + resolveArg("--instruction", argv) || + process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION || + undefined; const timeoutMs = parseNumber( - resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS, + resolveArg("--timeout-ms", argv) || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS, 240_000, "--timeout-ms", ); const pollMs = parseNumber( - resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS, + resolveArg("--poll-ms", argv) || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS, 1_500, "--poll-ms", ); - const stateDir = path.resolve(resolveArg("--state-dir") || resolveStateDir()); + const stateDir = path.resolve(resolveArg("--state-dir", argv) || resolveStateDir()); const openclawBin = - resolveArg("--openclaw-bin") || process.env.OPENCLAW_DISCORD_SMOKE_OPENCLAW_BIN || "openclaw"; - const json = hasFlag("--json"); + resolveArg("--openclaw-bin", argv) || + process.env.OPENCLAW_DISCORD_SMOKE_OPENCLAW_BIN || + "openclaw"; + const json = hasFlag("--json", argv); if (!channelId) { throw new Error(usage()); @@ -736,10 +794,10 @@ function printOutput(params: { json: boolean; payload: SuccessResult | FailureRe } } -async function run(): Promise { +async function run(argv = process.argv.slice(2)): Promise { let args: Args; try { - args = parseArgs(); + args = parseArgs(argv); } catch (err) { return { ok: false, @@ -1032,12 +1090,18 @@ async function run(): Promise { } } -async function main(): Promise { - if (hasFlag("--help") || hasFlag("-h")) { +async function main(argv = process.argv.slice(2)): Promise { + try { + validateCliArgs(argv); + } catch (err) { + writeStderrLine(safeErrorMessage(err)); + return 1; + } + if (hasFlag("--help", argv) || hasFlag("-h", argv)) { writeStdoutLine(usage()); return 0; } - const result = await run().catch( + const result = await run(argv).catch( (err: unknown): FailureResult => ({ ok: false, stage: "unexpected", @@ -1046,7 +1110,7 @@ async function main(): Promise { }), ); printOutput({ - json: hasFlag("--json"), + json: hasFlag("--json", argv), payload: result, }); return result.ok ? 0 : 1; @@ -1054,6 +1118,7 @@ async function main(): Promise { export const testing = { parseDriverMode, + parseArgs, parseNumber, DISCORD_RESPONSE_BODY_MAX_BYTES, redactDiscordApiPath, diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 9b3d40a21aff..9669bc61b4fa 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -79,6 +79,50 @@ describe("script-specific dev tooling hardening", () => { expect(() => discordSmokeTesting.parseDriverMode("curl")).toThrow(/Invalid --driver/u); }); + it("rejects unknown Discord smoke args before live Discord/OpenClaw work", () => { + expect(() => discordSmokeTesting.parseArgs(["--wat"])).toThrow("Unknown argument: --wat"); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/dev/discord-acp-plain-language-smoke.ts", "--wat"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.trim()).toBe("Unknown argument: --wat"); + }); + + it("prints Discord smoke usage without starting live validation", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/dev/discord-acp-plain-language-smoke.ts", "--help"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts"); + expect(result.stderr).toBe(""); + }); + + it("rejects missing Discord smoke option values before env fallbacks", () => { + expect(() => discordSmokeTesting.parseArgs(["--channel"])).toThrow( + "--channel requires a value", + ); + expect(() => discordSmokeTesting.parseArgs(["--channel="])).toThrow( + "--channel requires a value", + ); + expect(() => discordSmokeTesting.parseArgs(["--channel", "--json"])).toThrow( + "--channel requires a value", + ); + }); + it("redacts Discord webhook tokens from API paths", () => { const token = "webhook-secret-token-abcdef123456"; // pragma: allowlist secret const apiPath = `/webhooks/123/${token}?wait=true`;