diff --git a/README.md b/README.md index 6df471a..1f826ab 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Either way you get a real paid API response. No API keys. No native ETH. No brid | `selat skill run [--param value ...]` | Run an installed agent skill, passing its params as `--flags`. `--max-amount` is hard-capped at **$1.10** for everyone (`isTTY` / `--allow-high-max-amount` do not raise it). Requires an armed session budget (`selat budget start` writing `session.json`; env cannot arm). | | `selat skill compare "" --live-probe [--limit N] [--json] [--pay]` | Vet catalog candidates for an intent **side-by-side, without accounts or keys**: shortlists the top N (default 5) via the same federated discovery as `selat search`, free-probes each candidate's live 402 at its **catalog serviceUrl** (never settles; `--live-probe` is required — it acknowledges the probe may invoke each target's method/body), and prints one aligned table — live price, rail (direct x402 / routed MPP), probe latency, reachability, and the selat-skills registry reliability badge — sorted reachable-first, then price. `--json` for machine consumption. `--pay` adds one **capped settled test call** per candidate (asks for confirmation first; `--yes` to authorize non-interactively; `--max-amount` overrides the per-call cap, which otherwise defaults to live price + 25 % clamped to $1.10 — an explicit flag is also hard-capped at $1.10 for everyone; `isTTY` / `--allow-high-max-amount` do not raise it; requires a file-armed session budget) and saves each response body as an output sample. Apify prepaid-token candidates are probed but skipped by `--pay` — test those with `selat run`. | | `selat skill new/validate/verify/register/submit` | Author and contribute a skill: scaffold → static SOP check → live-402 verify (writes the receipt that gates submission) → index entry → PR to [selat-skills](https://github.com/SELAT-AI/selat-skills). `submit` opens the PR **from your fork by default** unless you have write access to the skills repo; `--fork` / `--no-fork` force either flow. | -| `selat fund [--chain ... --amount ... --method direct\|eco]` | Top up Gateway balance. Dry-runs first; requires explicit confirm. Refused while `selat freeze` is on. **Deposits have a 0.5 USDC minimum** (the Circle CLI's Gateway floor — `fund` refuses smaller amounts up front rather than mid-flow). The eco path requires **Circle CLI ≥1.0.0** and refuses older installs (their eco pipeline is the retired one that stranded deposits; fix with `circle update`). **Both methods are gasless** — the deposit runs through your agent wallet (a smart-contract account) with sponsored gas, so you never need to hold native ETH. The difference is **destination**: **`--method direct`** keeps the balance on the chain you deposited from; **`--method eco`** sources from Base (Circle CLI ≥1.0.0's eco coverage) but settles the resulting Gateway balance on **Polygon** regardless of source chain. After an Eco deposit, pay and check balance with `--chain polygon` (not the source chain), or the call fails with `insufficient_balance`. Fundable chains (base, optimism, arbitrum, polygon, ethereum, avalanche, unichain) are the intersection of [Gateway-supported](https://developers.circle.com/gateway/references/supported-blockchains) and [Agent-Wallet-supported](https://developers.circle.com/agent-stack/agent-wallets/supported-blockchains) chains — Gateway-only chains (e.g. HyperEVM, Sei, Sonic, World Chain) are excluded because the agent wallet doesn't exist there, and Agent-Wallet-only chains (e.g. Monad) have no Gateway deposit path. | +| `selat fund [--chain ... --amount ... --method direct\|eco]` | Top up Gateway balance. Dry-runs first; requires explicit confirm. Refused while `selat freeze` is on. **Deposits have a 0.5 USDC minimum** (the Circle CLI's Gateway floor — `fund` refuses smaller amounts up front rather than mid-flow). `selat` requires **Circle CLI ≥1.1.1** (`selat init` upgrades an older install; `selat doctor` reports the version). The eco path additionally refuses pre-1.0.0 installs (their eco pipeline is the retired one that stranded deposits). **Both methods are gasless** — the deposit runs through your agent wallet (a smart-contract account) with sponsored gas, so you never need to hold native ETH. The difference is **destination**: **`--method direct`** keeps the balance on the chain you deposited from; **`--method eco`** sources from Base (Circle CLI ≥1.0.0's eco coverage) but settles the resulting Gateway balance on **Polygon** regardless of source chain. After an Eco deposit, pay and check balance with `--chain polygon` (not the source chain), or the call fails with `insufficient_balance`. Fundable chains (base, optimism, arbitrum, polygon, ethereum, avalanche, unichain, arc — direct only) are the intersection of [Gateway-supported](https://developers.circle.com/gateway/references/supported-blockchains) and [Agent-Wallet-supported](https://developers.circle.com/agent-stack/agent-wallets/supported-blockchains) chains — Gateway-only chains (e.g. HyperEVM, Sei, Sonic, World Chain) are excluded because the agent wallet doesn't exist there, and Agent-Wallet-only chains (e.g. Monad) have no Gateway deposit path. | | `selat fund --onramp [--address 0x..]` | **Fiat funding (buy USDC with a card).** Mints a **Circle Onramp** session and prints/opens a browser widget URL — you pick the amount in the widget, and nothing moves until you complete the purchase there (the CLI never sees your card). Sessions are **chain-scoped** to the chains `selat fund` can deposit from, so purchased USDC never lands somewhere it would strand. `--address` targets any of your agent wallets (default: the configured one; a warning prints when they differ). This is SELAT's built-in onramp — **not** Transak (`circle wallet fund --method fiat`), which is a different, unscoped flow. Once the purchase lands on-chain, re-run `selat fund` to deposit into Gateway. | | `selat spend [--json\|--wallet 0x..]` | Unified spend report (read-only): settled spend from the `selat-pay` ledger (per-call payments + Apify token buys, with a charged-but-failed/disputable total) plus Apify token utilization (consumed vs remaining, flagging prepaid-balance waste). | | `selat refund claim\|query [--chain …]` | File or query a refund claim for a prior paid `selatx…` quote via `selat-pay`. Auth (SIWx), not a payment — does not consume session budget or `--max-amount`. Remaining flags (`--chain`, `--router-url`, signer flags) pass through unchanged. | diff --git a/lib/circle.mjs b/lib/circle.mjs index 1dc371d..7c0643f 100644 --- a/lib/circle.mjs +++ b/lib/circle.mjs @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { sh, hasBin } from "./sh.mjs"; +import { sh, hasBin, binVersion } from "./sh.mjs"; import { readConfig } from "./config.mjs"; import { adjustCircleCache, cachedCall, invalidateCircleCache } from "./circle-cache.mjs"; @@ -53,25 +53,79 @@ export async function hasCircle() { } /** - * Ensure the Circle CLI is installed. No-op if `circle` is already on PATH; - * otherwise install @circle-fin/cli globally. Installing the binary moves no - * money — wallet login (OTP), creation, and funding still happen interactively - * under the user's own Circle account, so self-custody is preserved. + * Oldest Circle CLI this stack works with. 1.1.1 is the first release that + * lists Arc mainnet (ARC) and accepts it as a `gateway deposit` source; the + * selat-discovery skill's Arc deposit path assumes it. Mirrored by the skill's + * `@circle-fin/cli` peer floor. + */ +export const MIN_CIRCLE_CLI_VERSION = "1.1.1"; + +/** Installed Circle CLI version ("1.1.1"), or null when unreadable / absent. */ +export async function circleVersion() { + if (!(await hasCircle())) return null; + const line = await binVersion(CIRCLE_BIN); + return line?.match(/\d+\.\d+\.\d+/)?.[0] ?? null; +} + +/** "1.1.1" >= "1.0.0" on dotted numeric versions. Unparseable → false. */ +export function versionAtLeast(version, min) { + if (!version) return false; + const parts = (v) => String(v).trim().split(".").map((n) => Number(n)); + const [a, b] = [parts(version), parts(min)]; + if (a.some((n) => !Number.isFinite(n))) return false; + for (let i = 0; i < 3; i++) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d > 0; + } + return true; +} + +/** + * What ensureCircle has to do: "ok" (installed and at/above the floor), + * "upgrade" (installed but older — or unreadable, which is treated as older + * so a broken shim gets reinstalled), or "install". Pure; exported for tests. + */ +export function circleInstallPlan({ installed, version, min = MIN_CIRCLE_CLI_VERSION }) { + if (!installed) return "install"; + return versionAtLeast(version, min) ? "ok" : "upgrade"; +} + +/** + * Ensure the Circle CLI is installed at or above MIN_CIRCLE_CLI_VERSION. + * No-op when it already is; otherwise `npm install -g @circle-fin/cli@latest` + * (which also upgrades an older install in place). Installing the binary + * moves no money — wallet login (OTP), creation, and funding still happen + * interactively under the user's own Circle account, so self-custody is + * preserved. * * Mirrors scripts/install-circle-cli.sh: retry with an isolated temp cache if * the default npm cache is broken (some agent hosts inherit stale root-owned - * npm state). Returns true if `circle` resolves on PATH afterward. - */ -export async function ensureCircle() { - if (await hasCircle()) return true; + * npm state). Returns true if `circle` resolves on PATH at/above the floor + * afterward. + */ +export async function ensureCircle({ log = () => {} } = {}) { + const plan = circleInstallPlan({ installed: await hasCircle(), version: await circleVersion() }); + if (plan === "ok") return true; + // A global npm install is a host mutation. Refuse it under the test runner + // (a fixture that mis-answers --version must not upgrade the developer's + // real Circle CLI) and when the caller opts out explicitly. + if (process.env.NODE_TEST_CONTEXT || process.env.SELAT_NO_INSTALL === "1") { + log(`Circle CLI ${plan} needed but installs are disabled (SELAT_NO_INSTALL / test context).`); + return false; + } if (!(await hasBin("npm"))) return false; - let r = await sh("npm", ["install", "-g", "@circle-fin/cli"], { inherit: true }); - if (r.code === 0 && (await hasCircle())) return true; + if (plan === "upgrade") { + log(`Circle CLI ${(await circleVersion()) ?? "(unreadable)"} is below the ${MIN_CIRCLE_CLI_VERSION} floor — upgrading…`); + } + const spec = "@circle-fin/cli@latest"; + const upToDate = async () => versionAtLeast(await circleVersion(), MIN_CIRCLE_CLI_VERSION); + let r = await sh("npm", ["install", "-g", spec], { inherit: true }); + if (r.code === 0 && (await upToDate())) return true; // os.tmpdir() honours TMPDIR on POSIX and TEMP/TMP on Windows, where the // previous `/tmp` fallback was not a valid path. const cache = join(tmpdir(), "selat-circle-npm-cache"); - r = await sh("npm", ["install", "-g", "@circle-fin/cli", "--cache", cache], { inherit: true }); - return r.code === 0 && (await hasCircle()); + r = await sh("npm", ["install", "-g", spec, "--cache", cache], { inherit: true }); + return r.code === 0 && (await upToDate()); } /** diff --git a/lib/commands/doctor.mjs b/lib/commands/doctor.mjs index d287cc2..c69bba9 100644 --- a/lib/commands/doctor.mjs +++ b/lib/commands/doctor.mjs @@ -15,7 +15,8 @@ import { sh, binVersion } from "../sh.mjs"; import { spendingPolicyByChain, describeChainPolicy, authStatus, getAgentAddress, gatewayBalancesByChain, listAgentWallets, usdcBalances, hasCircle, - lastCircleReadError, gatewayBalanceCacheKey, GATEWAY_BALANCE_TTL_MS } from "../circle.mjs"; + lastCircleReadError, gatewayBalanceCacheKey, GATEWAY_BALANCE_TTL_MS, + circleVersion, versionAtLeast, MIN_CIRCLE_CLI_VERSION } from "../circle.mjs"; import { circleCacheDisabled, circleCacheStatus } from "../circle-cache.mjs"; import { resolveSelatPay, selatPayVersion } from "../selat-pay.mjs"; import { readConfig, configPath } from "../config.mjs"; @@ -80,7 +81,10 @@ export async function doctor(args = []) { // Circle CLI console.log(fmt.bold("\nCircle CLI:")); if (await hasCircle()) { - pass("circle binary on PATH"); + const v = await circleVersion(); + if (versionAtLeast(v, MIN_CIRCLE_CLI_VERSION)) pass(`circle ${v} on PATH`); + else if (v) fail(`circle ${v} is below the ${MIN_CIRCLE_CLI_VERSION} floor (Arc mainnet needs it) — run \`selat init\` or: npm install -g @circle-fin/cli@latest`); + else warn(`circle binary on PATH but \`circle --version\` is unreadable — expected >= ${MIN_CIRCLE_CLI_VERSION}`); const status = await authStatus(); if (status.authed) pass(`authenticated${status.email ? " as " + status.email : ""}`); else fail("not authenticated — run `selat init`"); diff --git a/lib/commands/init.mjs b/lib/commands/init.mjs index e30a00f..0a7bb46 100644 --- a/lib/commands/init.mjs +++ b/lib/commands/init.mjs @@ -18,6 +18,9 @@ import { binVersion, isWindows } from "../sh.mjs"; import { hasCircle, ensureCircle, + circleVersion, + versionAtLeast, + MIN_CIRCLE_CLI_VERSION, authStatus, login, createWallets, @@ -99,16 +102,20 @@ export async function init(args) { // [3/8] Circle CLI console.log(fmt.step(3, STEPS, "Checking Circle CLI")); - if (await hasCircle()) { - console.log(" " + fmt.ok("Circle CLI already on PATH")); + const installedCircle = await circleVersion(); + if ((await hasCircle()) && versionAtLeast(installedCircle, MIN_CIRCLE_CLI_VERSION)) { + console.log(" " + fmt.ok(`Circle CLI ${installedCircle} on PATH`)); } else { - // Auto-install the Circle CLI rather than bouncing the user out of setup. - // Installing the binary moves no money — wallet login (OTP), creation, and - // funding still happen interactively under the user's own Circle account - // in the steps below, preserving self-custody. - console.log(" " + fmt.dim("Circle CLI not found — installing @circle-fin/cli…")); - if (await ensureCircle()) { - console.log(" " + fmt.ok("Circle CLI installed")); + // Auto-install (or upgrade) the Circle CLI rather than bouncing the user + // out of setup. Installing the binary moves no money — wallet login (OTP), + // creation, and funding still happen interactively under the user's own + // Circle account in the steps below, preserving self-custody. + const why = (await hasCircle()) + ? `Circle CLI ${installedCircle ?? "(unreadable version)"} is below the ${MIN_CIRCLE_CLI_VERSION} floor — upgrading` + : "Circle CLI not found — installing"; + console.log(" " + fmt.dim(`${why} @circle-fin/cli@latest…`)); + if (await ensureCircle({ log: (m) => console.log(" " + fmt.dim(m)) })) { + console.log(" " + fmt.ok(`Circle CLI ${await circleVersion()} ready`)); } else { console.error(" " + fmt.err("Circle CLI auto-install failed.")); console.error(" Install it manually: npm install -g @circle-fin/cli, then run `selat init` again."); diff --git a/lib/sh.mjs b/lib/sh.mjs index 3427ae0..3034cff 100644 --- a/lib/sh.mjs +++ b/lib/sh.mjs @@ -4,7 +4,8 @@ import { spawn, spawnSync } from "node:child_process"; import os from "node:os"; -import { statSync } from "node:fs"; +import { statSync, accessSync, constants } from "node:fs"; +import { join, delimiter } from "node:path"; export const isWindows = process.platform === "win32"; @@ -151,11 +152,29 @@ export async function hasBin(name) { const where = await sh("where", [name]).catch(() => ({ code: 1, stdout: "" })); return where.code === 0 && where.stdout.trim().length > 0; } - const result = await sh("command", ["-v", name]).catch(() => ({ code: 1 })); - if (result.code === 0 && result.stdout.trim()) return true; - // Fallback: try `which` on macOS/Linux. - const which = await sh("which", [name]).catch(() => ({ code: 1 })); - return which.code === 0 && which.stdout.trim().length > 0; + // Resolve against PATH directly rather than via `command -v` / `which`: + // those are themselves PATH lookups, so under a restricted PATH (test + // sandboxes, minimal containers) they vanish and every binary reads as + // missing — which is how a fixture `circle` went undetected and init + // tried to install one. No subprocess, so it is also cheaper on the + // `selat run` hot path. + return resolveOnPath(name) != null; +} + +/** First executable named `name` on PATH, or null. POSIX only. */ +export function resolveOnPath(name, { path = process.env.PATH ?? "" } = {}) { + for (const dir of path.split(delimiter)) { + if (!dir) continue; + const candidate = join(dir, name); + try { + if (!statSync(candidate).isFile()) continue; + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not there / not executable — keep looking + } + } + return null; } /** diff --git a/test/chain-free-policy.test.mjs b/test/chain-free-policy.test.mjs index 911a886..560387c 100644 --- a/test/chain-free-policy.test.mjs +++ b/test/chain-free-policy.test.mjs @@ -6,6 +6,7 @@ import { mkdtempSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { closedEnv } from "./helpers/closed-env.mjs"; // Design principle, REVISED 2026-08-11: the BALANCE and BUDGET surfaces stay // chain-free (Gateway is one balance; the budget rows read identically on @@ -151,15 +152,14 @@ const pexecFile = promisify(execFile); const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url)); function budgetEnv() { - return { - ...process.env, + return closedEnv({ CIRCLE_BIN: fakeCircle, SELAT_AGENT_WALLET_ADDRESS: ADDRESS, SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"), SELAT_PAY_HISTORY_PATH: join(dir, "no-history.jsonl"), SELAT_PAY_FREEZE_PATH: join(dir, "no-freeze.json"), NO_COLOR: "1" - }; + }); } test("`selat budget` renders the true CUSTOM caps with zero chain language", async () => { diff --git a/test/circle-cache.test.mjs b/test/circle-cache.test.mjs index 89511d6..a69a754 100644 --- a/test/circle-cache.test.mjs +++ b/test/circle-cache.test.mjs @@ -6,6 +6,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileS import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; +import { closedEnv } from "./helpers/closed-env.mjs"; // The Circle CLI costs 4–17 s per spawn and `selat run` spawned it before // every payment just to pick the funded --chain. lib/circle-cache.mjs keeps @@ -226,8 +227,7 @@ const pexecFile = promisify(execFile); const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url)); const runSelat = (args, extraEnv = {}) => pexecFile(process.execPath, [selatBin, ...args], { - env: { - ...process.env, + env: closedEnv({ CIRCLE_BIN: fakeCircle, XDG_STATE_HOME: stateHome, XDG_CONFIG_HOME: configHome, @@ -236,7 +236,7 @@ const runSelat = (args, extraEnv = {}) => SELAT_PAY_FREEZE_PATH: join(dir, "no-freeze.json"), NO_COLOR: "1", ...extraEnv - } + }) }).catch((e) => e); // A stale-looking entry that says Base holds the funds; the fake circle says diff --git a/test/circle-version-floor.test.mjs b/test/circle-version-floor.test.mjs new file mode 100644 index 0000000..2d0dbdd --- /dev/null +++ b/test/circle-version-floor.test.mjs @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { MIN_CIRCLE_CLI_VERSION, versionAtLeast, circleInstallPlan } from "../lib/circle.mjs"; + +// The stack needs Circle CLI >= 1.1.1 (first release that lists Arc mainnet). +// ensureCircle() decides install / upgrade / ok from this pure plan so an +// existing older install is upgraded instead of silently kept. + +test("floor is 1.1.1", () => { + assert.equal(MIN_CIRCLE_CLI_VERSION, "1.1.1"); +}); + +test("versionAtLeast compares dotted numerics, rejects garbage", () => { + assert.equal(versionAtLeast("1.1.1", "1.1.1"), true); + assert.equal(versionAtLeast("1.2.0", "1.1.1"), true); + assert.equal(versionAtLeast("2.0.0", "1.1.1"), true); + assert.equal(versionAtLeast("1.1.0", "1.1.1"), false); + assert.equal(versionAtLeast("1.0.0", "1.1.1"), false); + assert.equal(versionAtLeast(null, "1.1.1"), false); + assert.equal(versionAtLeast("1.1.1-canary", "1.1.1"), false); +}); + +test("circleInstallPlan: absent → install, old → upgrade, current → ok", () => { + assert.equal(circleInstallPlan({ installed: false, version: null }), "install"); + assert.equal(circleInstallPlan({ installed: true, version: "1.0.0" }), "upgrade"); + // Unreadable version on an installed binary is treated as old (reinstall). + assert.equal(circleInstallPlan({ installed: true, version: null }), "upgrade"); + assert.equal(circleInstallPlan({ installed: true, version: "1.1.1" }), "ok"); + assert.equal(circleInstallPlan({ installed: true, version: "1.3.0" }), "ok"); +}); diff --git a/test/fund-help-guard.test.mjs b/test/fund-help-guard.test.mjs index 0104054..f586d1d 100644 --- a/test/fund-help-guard.test.mjs +++ b/test/fund-help-guard.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { closedEnv } from "./helpers/closed-env.mjs"; const pexec = promisify(execFile); const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url)); @@ -14,7 +15,7 @@ const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url)); async function run(args) { try { - const { stdout, stderr } = await pexec("node", [selatBin, ...args], { input: "" }); + const { stdout, stderr } = await pexec(process.execPath, [selatBin, ...args], { input: "", env: closedEnv() }); return { code: 0, stdout, stderr }; } catch (err) { return { code: err.code ?? 1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; diff --git a/test/helpers/closed-env.mjs b/test/helpers/closed-env.mjs new file mode 100644 index 0000000..b4459a8 --- /dev/null +++ b/test/helpers/closed-env.mjs @@ -0,0 +1,47 @@ +import { mkdtempSync, symlinkSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; + +/** + * Environment for tests that spawn `bin/selat.mjs`. + * + * The CLI shells out (`circle`, `npm`, `node`, `selat-pay`, …). A test that + * inherits the host PATH lets every fixture gap fall through to the real + * binary — which is how a fake `circle` with no `--version` once ran a real + * `npm install -g @circle-fin/cli@latest` on a developer machine. + * + * So PATH here is: the fixture dirs the test hands over, then ONE private + * tools dir holding symlinks to exactly `node` (the CLI spawns it for the + * skill and selat-pay), `sh`, `bash` and `env` (fixture shebangs). Nothing + * else resolves — not `npm`, not the real `circle` — even on nvm installs, + * where node's own bin dir would otherwise bring the whole global tree along. + */ +let toolsDir; +function tools() { + if (toolsDir) return toolsDir; + toolsDir = mkdtempSync(join(tmpdir(), "selat-test-tools-")); + const links = { + node: process.execPath, + sh: "/bin/sh", + bash: "/bin/bash", + env: "/usr/bin/env", + }; + for (const [name, target] of Object.entries(links)) { + if (existsSync(target)) symlinkSync(target, join(toolsDir, name)); + } + return toolsDir; +} + +export function closedPath(...bins) { + return [...bins, tools()].join(delimiter); +} + +export function closedEnv(overrides = {}, { bins = [] } = {}) { + return { + ...process.env, + PATH: closedPath(...bins), + // Never let a spawned CLI install anything, whatever PATH says. + SELAT_NO_INSTALL: "1", + ...overrides, + }; +} diff --git a/test/init-and-history.test.mjs b/test/init-and-history.test.mjs index 1a88d9c..7da98ba 100644 --- a/test/init-and-history.test.mjs +++ b/test/init-and-history.test.mjs @@ -5,6 +5,7 @@ import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { closedEnv } from "./helpers/closed-env.mjs"; import { init, noUsdcHintLines, walletArg, resolveWalletPreset } from "../lib/commands/init.mjs"; import { ensureSelatPayHistoryDir } from "../lib/selat-pay.mjs"; @@ -120,6 +121,10 @@ test("init reuses configured wallet when Circle wallet listing fails", async () fakeCircle, `#!/usr/bin/env bash set -euo pipefail +if [[ "\${1:-}" == "--version" ]]; then + printf '1.1.1\\n' + exit 0 +fi if [[ "\${1:-}" == "wallet" && "\${2:-}" == "status" ]]; then printf 'Type: agent\\nEmail: test@example.com\\nStatus: VALID\\n' exit 0 @@ -138,14 +143,14 @@ exit 2 "utf8" ); await chmod(fakeCircle, 0o755); + // Belt and braces: the spawned init must never reach the real npm. + const fakeNpm = join(binDir, "npm"); + await writeFile(fakeNpm, "#!/usr/bin/env bash\nprintf 'test fixture: npm must not run (argv: %s)\\n' \"$*\" >&2\nexit 97\n", "utf8"); + await chmod(fakeNpm, 0o755); const result = await runNode(["bin/selat.mjs", "init"], { cwd: new URL("..", import.meta.url).pathname, - env: { - ...process.env, - XDG_CONFIG_HOME: xdg, - PATH: `${binDir}:${process.env.PATH}` - } + env: closedEnv({ XDG_CONFIG_HOME: xdg }, { bins: [binDir] }) }); assert.equal(result.code, 0, result.stderr || result.stdout); diff --git a/test/init-circle-autoinstall.test.mjs b/test/init-circle-autoinstall.test.mjs new file mode 100644 index 0000000..44f18eb --- /dev/null +++ b/test/init-circle-autoinstall.test.mjs @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { closedEnv } from "./helpers/closed-env.mjs"; + +// `selat init` installs the Circle CLI when it's missing and upgrades it when +// it's below MIN_CIRCLE_CLI_VERSION — automatically, no prompt or flag. These +// run under a closed PATH with a fake npm, and ensureCircle refuses under +// NODE_TEST_CONTEXT, so the attempt is observable but can never reach the +// host: "Installing/Upgrading …" followed by the guard's refusal. + +const pexec = promisify(execFile); +const selatBin = new URL("../bin/selat.mjs", import.meta.url).pathname; + +async function fixture({ circleVersion }) { + const root = await mkdtemp(join(tmpdir(), "selat-init-autoinstall-")); + const binDir = join(root, "bin"); + await mkdir(binDir, { recursive: true }); + if (circleVersion !== null) { + await writeFile(join(binDir, "circle"), `#!/bin/sh\n[ "$1" = "--version" ] && { echo ${circleVersion}; exit 0; }\necho "unexpected circle $*" >&2; exit 2\n`); + await chmod(join(binDir, "circle"), 0o755); + } + await writeFile(join(binDir, "npm"), `#!/bin/sh\necho "FAKE-NPM $*" >&2; exit 97\n`); + await chmod(join(binDir, "npm"), 0o755); + return binDir; +} + +async function runInit(binDir) { + try { + const { stdout, stderr } = await pexec(process.execPath, [selatBin, "init"], { + input: "", + env: closedEnv({ XDG_CONFIG_HOME: join(binDir, "..", "xdg"), NO_COLOR: "1", SELAT_NO_INSTALL: "" }, { bins: [binDir] }), + }); + return { code: 0, out: stdout + stderr }; + } catch (err) { + return { code: err.code ?? 1, out: (err.stdout ?? "") + (err.stderr ?? "") }; + } +} + +test("missing Circle CLI: init attempts the install without asking", async () => { + const r = await runInit(await fixture({ circleVersion: null })); + assert.match(r.out, /Circle CLI not found — installing @circle-fin\/cli@latest/); + assert.match(r.out, /installs are disabled/, "guard, not npm, stopped it"); + assert.doesNotMatch(r.out, /FAKE-NPM/); + assert.equal(r.code, 1); +}); + +test("old Circle CLI: init attempts the upgrade without asking", async () => { + const r = await runInit(await fixture({ circleVersion: "1.0.0" })); + assert.match(r.out, /1\.0\.0 is below the 1\.1\.1 floor — upgrading @circle-fin\/cli@latest/); + assert.match(r.out, /installs are disabled/); + assert.doesNotMatch(r.out, /FAKE-NPM/); +}); + +test("current Circle CLI: init leaves it alone", async () => { + const r = await runInit(await fixture({ circleVersion: "1.1.1" })); + assert.match(r.out, /Circle CLI 1\.1\.1 on PATH/); + assert.doesNotMatch(r.out, /installing|upgrading|installs are disabled|FAKE-NPM/); +}); diff --git a/test/refund.test.mjs b/test/refund.test.mjs index 0a444f3..3239e85 100644 --- a/test/refund.test.mjs +++ b/test/refund.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { closedEnv } from "./helpers/closed-env.mjs"; import { selatPaySpawn } from "../lib/selat-pay.mjs"; import { @@ -28,7 +29,7 @@ function quiet(t) { async function runBin(args) { try { - const { stdout, stderr } = await pexec("node", [selatBin, ...args], { input: "" }); + const { stdout, stderr } = await pexec(process.execPath, [selatBin, ...args], { input: "", env: closedEnv() }); return { code: 0, stdout, stderr }; } catch (err) { return { code: err.code ?? 1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; diff --git a/test/run-arg-parsing.test.mjs b/test/run-arg-parsing.test.mjs index 2468d53..9eaebf6 100644 --- a/test/run-arg-parsing.test.mjs +++ b/test/run-arg-parsing.test.mjs @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { closedEnv } from "./helpers/closed-env.mjs"; import { parseRunArgs, KNOWN_RUN_FLAGS } from "../lib/commands/run.mjs"; @@ -122,7 +123,7 @@ test("selat run --help lists --max-amount", async () => { const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const run = promisify(execFile); - const r = await run("node", ["bin/selat.mjs", "run", "--help"]); + const r = await run(process.execPath, ["bin/selat.mjs", "run", "--help"], { env: closedEnv() }); assert.match(r.stdout, /--max-amount /); assert.match(r.stdout, /Hard CLI ceiling \$1\.1/); assert.match(r.stdout, /--allow-high-max-amount/); @@ -133,7 +134,7 @@ test("run --json emits JSON on arg-parse and missing-intent errors", async () => const { promisify } = await import("node:util"); const run = promisify(execFile); for (const argv of [["run", "--json", "--bogus"], ["run", "--json"]]) { - const r = await run("node", ["bin/selat.mjs", ...argv]).catch((e) => e); + const r = await run(process.execPath, ["bin/selat.mjs", ...argv], { env: closedEnv() }).catch((e) => e); const parsed = JSON.parse(r.stdout.trim()); assert.equal(parsed.ok, false); assert.ok(parsed.error.length > 0); @@ -144,7 +145,7 @@ test("skill run --json emits JSON when the skill is not installed", async () => const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const run = promisify(execFile); - const r = await run("node", ["bin/selat.mjs", "skill", "run", "nope-not-installed", "--json"]).catch((e) => e); + const r = await run(process.execPath, ["bin/selat.mjs", "skill", "run", "nope-not-installed", "--json"], { env: closedEnv() }).catch((e) => e); const parsed = JSON.parse(r.stdout.trim()); assert.equal(parsed.ok, false); assert.match(parsed.error, /not installed/); diff --git a/test/skill-verify-json.test.mjs b/test/skill-verify-json.test.mjs index 82f0b9a..e09b299 100644 --- a/test/skill-verify-json.test.mjs +++ b/test/skill-verify-json.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { closedEnv } from "./helpers/closed-env.mjs"; const pexec = promisify(execFile); const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url)); @@ -15,7 +16,7 @@ const selatBin = fileURLToPath(new URL("../bin/selat.mjs", import.meta.url)); async function run(args) { try { - const { stdout } = await pexec("node", [selatBin, ...args]); + const { stdout } = await pexec(process.execPath, [selatBin, ...args], { env: closedEnv() }); return { code: 0, stdout }; } catch (err) { return { code: err.code ?? 1, stdout: err.stdout ?? "" }; diff --git a/test/spend-guard.test.mjs b/test/spend-guard.test.mjs index c55f102..b7b615c 100644 --- a/test/spend-guard.test.mjs +++ b/test/spend-guard.test.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { closedEnv } from "./helpers/closed-env.mjs"; import { AGENT_HARNESS_ENV_KEYS, @@ -254,7 +255,7 @@ test("readSessionConfig: env cannot invent a budget; may lower a file budget", ( test("paid selat run without a session budget refuses (does not rank or pay)", async () => { const dir = mkdtempSync(join(tmpdir(), "selat-run-budget-")); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"), }; delete env.SELAT_SESSION_BUDGET; @@ -270,7 +271,7 @@ test("paid selat run without a session budget refuses (does not rank or pay)", a test("SELAT_SESSION_BUDGET=99999 with no session.json still refuses paid run", async () => { const dir = mkdtempSync(join(tmpdir(), "selat-run-env-budget-")); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"), SELAT_SESSION_BUDGET: "99999", SELAT_SESSION_ID: "spoofed", @@ -289,7 +290,7 @@ test("file-armed session.json allows the paid path past the budget gate", async const session = join(dir, "session.json"); writeFileSync(session, JSON.stringify({ sessionId: "s-file", budgetUsd: 2 })); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: session, SELAT_SKILL_PATH: join(dir, "no-such-skill"), }; @@ -307,7 +308,7 @@ test("harness env + --allow-high-max-amount 5 refuses the $1.10 ceiling on paid const session = join(dir, "session.json"); writeFileSync(session, JSON.stringify({ sessionId: "s-h", budgetUsd: 10 })); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: session, CURSOR_AGENT: "1", CLAUDECODE: "1", @@ -328,7 +329,7 @@ test("paid run: --max-amount 1.1 passes the hard ceiling; 1.11 is refused before const session = join(dir, "session.json"); writeFileSync(session, JSON.stringify({ sessionId: "s-cap", budgetUsd: 10 })); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: session, SELAT_SKILL_PATH: join(dir, "no-such-skill"), }; @@ -357,7 +358,7 @@ test("selat budget start writes session.json and is not blocked by missing env", const dir = mkdtempSync(join(tmpdir(), "selat-budget-start-")); const session = join(dir, "session.json"); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: session, }; delete env.SELAT_SESSION_BUDGET; @@ -373,7 +374,7 @@ test("cwd .env with SELAT_SESSION_BUDGET does not arm a paid run", async () => { const dir = mkdtempSync(join(tmpdir(), "selat-cwd-env-")); writeFileSync(join(dir, ".env"), "SELAT_SESSION_BUDGET=99999\nSELAT_SESSION_ID=cwd\n"); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"), }; delete env.SELAT_SESSION_BUDGET; @@ -387,7 +388,7 @@ test("cwd .env with SELAT_SESSION_BUDGET does not arm a paid run", async () => { test("selat run --dry-run does not require a session budget (free preview)", async () => { const dir = mkdtempSync(join(tmpdir(), "selat-run-dry-")); const env = { - ...process.env, + ...closedEnv(), SELAT_PAY_SESSION_PATH: join(dir, "no-session.json"), SELAT_SKILL_PATH: join(dir, "no-such-skill"), };