Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions lib/commands/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ import {
authorizeExplicitMaxAmount,
} from "../spend-guard.mjs";

const RUN_USAGE = `usage: selat run "<intent>" [--capability <name>] [--endpoint <url> [--method <verb>]] [--max-amount <usd>] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '<json>' | --input-file <path>] [--auto-rebuy] [--json] [--verbose]`;
const RUN_USAGE = `usage: selat run "<intent>" [--capability <name>] [--endpoint <url> [--method <verb>] [--allow-unlisted]] [--max-amount <usd>] [${ALLOW_HIGH_MAX_AMOUNT_FLAG}] [--dry-run] [--live-probe] [--param key=value ...] [--input '<json>' | --input-file <path>] [--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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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; }
Expand All @@ -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 <url>" };
}
return { ok: true, ...opts, intent: intentTokens.join(" ").trim() };
}

Expand Down Expand Up @@ -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 }),
];
}
Expand All @@ -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) {
Expand Down Expand Up @@ -237,12 +248,20 @@ export async function run(args) {
console.log(" --input-file <path> Actor input from a JSON file");
console.log(" --auto-rebuy Apify only: buy a replacement token if it drains mid-run");
console.log(" --endpoint <url> 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 <verb> 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 <name> Rank only endpoints labeled with this capability");
console.log(" (Layer 0). Unknown names and empty labeled pools");
console.log(" are refused, never silently widened.");
Expand All @@ -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)] });
}
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/run-allow-unlisted.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});