diff --git a/lib/commands/run.mjs b/lib/commands/run.mjs index 6bc8174..87d8970 100644 --- a/lib/commands/run.mjs +++ b/lib/commands/run.mjs @@ -39,11 +39,11 @@ import { authorizeExplicitMaxAmount, } from "../spend-guard.mjs"; -const RUN_USAGE = `usage: selat run "" [--capability ] [--endpoint [--method ]] [--max-amount ] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '' | --input-file ] [--auto-rebuy] [--json] [--verbose]`; +const RUN_USAGE = `usage: selat run "" [--capability ] [--endpoint [--method ] [--allow-unlisted]] [--max-amount ] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '' | --input-file ] [--auto-rebuy] [--json] [--verbose]`; // Every flag `selat run` understands. Kept as data so the unknown-flag error // can list them and tests can pin the set. -export const KNOWN_RUN_FLAGS = ["--dry-run", "--live-probe", "--param", "--input", "--input-file", "--auto-rebuy", "--endpoint", "--method", "--capability", "--max-amount", ALLOW_HIGH_MAX_AMOUNT_FLAG, "--json", "--verbose"]; +export const KNOWN_RUN_FLAGS = ["--dry-run", "--live-probe", "--param", "--input", "--input-file", "--auto-rebuy", "--endpoint", "--method", "--allow-unlisted", "--capability", "--max-amount", ALLOW_HIGH_MAX_AMOUNT_FLAG, "--json", "--verbose"]; // Conservative per-call ceiling. Catalog exec_hints are untrusted; a hint // above this is clamped. An explicit --max-amount is ALSO clamped to this @@ -80,7 +80,7 @@ export const DEFAULT_RUN_MAX_AMOUNT_USD = HARD_CLI_MAX_AMOUNT_USD; * to (tester feedback round 2). */ export function parseRunArgs(args) { - const opts = { inputInline: undefined, inputFile: undefined, autoRebuy: false, dryRun: false, liveProbe: false, jsonMode: false, verbose: false, rawParams: [], endpoint: undefined, method: undefined, capability: undefined, maxAmount: undefined, allowHighMaxAmount: false }; + const opts = { inputInline: undefined, inputFile: undefined, autoRebuy: false, dryRun: false, liveProbe: false, jsonMode: false, verbose: false, rawParams: [], endpoint: undefined, method: undefined, capability: undefined, maxAmount: undefined, allowHighMaxAmount: false, allowUnlisted: false }; const intentTokens = []; for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -122,6 +122,7 @@ export function parseRunArgs(args) { } continue; } + if (a === "--allow-unlisted") { opts.allowUnlisted = true; continue; } if (a === "--auto-rebuy") { opts.autoRebuy = true; continue; } if (a === ALLOW_HIGH_MAX_AMOUNT_FLAG) { opts.allowHighMaxAmount = true; continue; } if (a === "--dry-run") { opts.dryRun = true; continue; } @@ -133,6 +134,12 @@ export function parseRunArgs(args) { } intentTokens.push(a); } + // --allow-unlisted is an opt-out of the catalog gate for one PINNED URL; + // without --endpoint there is nothing it could apply to, and silently + // ignoring it would teach callers a flag that does nothing. + if (opts.allowUnlisted && !opts.endpoint) { + return { ok: false, error: "--allow-unlisted requires --endpoint " }; + } return { ok: true, ...opts, intent: intentTokens.join(" ").trim() }; } @@ -161,12 +168,16 @@ export function capabilityArgs({ capability } = {}) { * seam tests pin so --live-probe / --endpoint / --capability cannot drift from * the spawn. */ -export function rankPickArgv({ intent, liveProbe = false, endpoint, method, capability } = {}) { +export function rankPickArgv({ intent, liveProbe = false, endpoint, method, capability, allowUnlisted = false } = {}) { return [ intent, "--pick", ...(liveProbe ? ["--live-probe"] : []), ...pinArgs({ endpoint, method }), + // Only meaningful with a pin: an explicit per-call opt-out of the catalog + // gate. Never emitted without --endpoint, so an intent-driven run can't + // accidentally widen into unlisted territory. + ...(allowUnlisted && endpoint ? ["--allow-unlisted"] : []), ...capabilityArgs({ capability }), ]; } @@ -186,7 +197,7 @@ export function pinRefusal(code, endpoint) { if (code === 4) { return { reason: "endpoint-not-in-catalog", - error: "the pinned endpoint is not in the federated catalog — nothing was charged", + error: "the pinned endpoint is not in the federated catalog — nothing was charged. Pass --allow-unlisted to pin it anyway (live 402 supplies the terms; spend caps still apply)", }; } if (code === 5) { @@ -237,12 +248,20 @@ export async function run(args) { console.log(" --input-file Actor input from a JSON file"); console.log(" --auto-rebuy Apify only: buy a replacement token if it drains mid-run"); console.log(" --endpoint Pay this exact endpoint instead of letting the intent"); - console.log(" pick one. Must be in the federated catalog; an unlisted"); - console.log(" URL is refused, never paid. Use it to pay the endpoint"); + console.log(" pick one. Must be in the federated catalog unless"); + console.log(" --allow-unlisted is set; an unlisted URL is refused by"); + console.log(" default, never paid. Use it to pay the endpoint"); console.log(" you were quoted — re-ranking can otherwise resolve to a"); console.log(" different endpoint at a different price."); console.log(" --method Disambiguate --endpoint when one URL is listed under"); - console.log(" several methods"); + console.log(" several methods; sets the verb for --allow-unlisted"); + console.log(" pins (default GET)"); + console.log(" --allow-unlisted Let --endpoint pin an https URL no federated catalog"); + console.log(" lists. No catalog terms exist: the live 402 challenge is"); + console.log(" the only source of price and rail, and the default cap"); + console.log(" is the flat no-price $0.10 (raise with --max-amount; the"); + console.log(" $1 hard ceiling, validate-before-sign, and Circle policy"); + console.log(" caps still apply). Explicit per call, never implied."); console.log(" --capability Rank only endpoints labeled with this capability"); console.log(" (Layer 0). Unknown names and empty labeled pools"); console.log(" are refused, never silently widened."); @@ -259,7 +278,7 @@ export async function run(args) { console.log(" -h, --help Show this help. Never ranks, never pays."); return 0; } - const { intent, inputInline, inputFile, autoRebuy, dryRun, liveProbe, jsonMode, verbose, rawParams, endpoint, method, capability, maxAmount, allowHighMaxAmount } = parsedArgs; + const { intent, inputInline, inputFile, autoRebuy, dryRun, liveProbe, jsonMode, verbose, rawParams, endpoint, method, capability, maxAmount, allowHighMaxAmount, allowUnlisted } = parsedArgs; if (!intent) { return emitRunError({ jsonMode, error: "an intent is required", hints: [fmt.dim(RUN_USAGE)] }); } @@ -314,7 +333,7 @@ export async function run(args) { // Step 1: pick const pick = await sh( "node", - [join(skill.path, "scripts", "rank.mjs"), ...rankPickArgv({ intent, liveProbe, endpoint, method, capability })] + [join(skill.path, "scripts", "rank.mjs"), ...rankPickArgv({ intent, liveProbe, endpoint, method, capability, allowUnlisted })] ); if (pick.code !== 0) { // A pin refusal is not a rank failure — it is rank.mjs doing its job, and a diff --git a/test/run-allow-unlisted.test.mjs b/test/run-allow-unlisted.test.mjs new file mode 100644 index 0000000..fcbb282 --- /dev/null +++ b/test/run-allow-unlisted.test.mjs @@ -0,0 +1,42 @@ +// --allow-unlisted: explicit per-call opt-out of the catalog gate for a +// pinned URL. Seam tests pin the parse and the rank.mjs argv so the flag +// cannot drift from the spawn, and the guards that make it safe to offer: +// never without --endpoint, never implied. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseRunArgs, rankPickArgv, KNOWN_RUN_FLAGS, pinRefusal } from "../lib/commands/run.mjs"; + +test("--allow-unlisted is a known flag and parses with --endpoint", () => { + assert.ok(KNOWN_RUN_FLAGS.includes("--allow-unlisted")); + const parsed = parseRunArgs(["paid delivery check", "--endpoint", "https://api.x.dev/v1", "--allow-unlisted"]); + assert.equal(parsed.ok, true); + assert.equal(parsed.allowUnlisted, true); + assert.equal(parsed.endpoint, "https://api.x.dev/v1"); +}); + +test("--allow-unlisted without --endpoint is refused at parse time", () => { + const parsed = parseRunArgs(["paid delivery check", "--allow-unlisted"]); + assert.equal(parsed.ok, false); + assert.match(parsed.error, /--allow-unlisted requires --endpoint/); +}); + +test("rankPickArgv forwards --allow-unlisted only alongside a pin", () => { + assert.deepEqual( + rankPickArgv({ intent: "x", endpoint: "https://api.x.dev/v1", allowUnlisted: true }), + ["x", "--pick", "--endpoint", "https://api.x.dev/v1", "--allow-unlisted"], + ); + // Defensive: even if a caller sets the flag without a pin, it is not emitted. + assert.deepEqual(rankPickArgv({ intent: "x", allowUnlisted: true }), ["x", "--pick"]); + // Absent by default. + assert.deepEqual( + rankPickArgv({ intent: "x", endpoint: "https://api.x.dev/v1" }), + ["x", "--pick", "--endpoint", "https://api.x.dev/v1"], + ); +}); + +test("the not-in-catalog refusal names the flag", () => { + const refusal = pinRefusal(4, "https://api.x.dev/v1"); + assert.equal(refusal.reason, "endpoint-not-in-catalog"); + assert.match(refusal.error, /--allow-unlisted/); +});