Skip to content

Commit d90f533

Browse files
authored
Merge pull request #227 from kychee-com/claude/bugs-batch-b
Bug triage round 2: safety + SDK validation (8 fixes)
2 parents 11e3e58 + c777204 commit d90f533

21 files changed

Lines changed: 1451 additions & 88 deletions

cli-e2e.test.mjs

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1979,7 +1979,7 @@ describe("CLI e2e happy path", () => {
19791979
tempoRpcCallCount = 0; // reset for fresh faucet flow
19801980
const { run } = await import("./cli/lib/init.mjs");
19811981
captureStart();
1982-
await run(["mpp"]);
1982+
await run(["mpp", "--switch-rail"]);
19831983
captureStop();
19841984
const out = captured();
19851985
assert.ok(out.includes("Tempo"), "should show Tempo network");
@@ -1998,7 +1998,7 @@ describe("CLI e2e happy path", () => {
19981998
tempoRpcCallCount = 0; // first eth_call returns 0 → triggers faucet path
19991999
const { run } = await import("./cli/lib/init.mjs");
20002000
captureStart();
2001-
await run(["mpp", "--json"]);
2001+
await run(["mpp", "--json", "--switch-rail"]);
20022002
captureStop();
20032003
const stdout = capturedStdout();
20042004
const parsed = JSON.parse(stdout);
@@ -2042,7 +2042,7 @@ describe("CLI e2e happy path", () => {
20422042
it("init (switch back to x402)", async () => {
20432043
const { run } = await import("./cli/lib/init.mjs");
20442044
captureStart();
2045-
await run([]);
2045+
await run(["--switch-rail"]);
20462046
captureStop();
20472047
const out = captured();
20482048
assert.ok(out.includes("Base Sepolia"), "should show Base Sepolia network");
@@ -2704,3 +2704,144 @@ describe("CLI destructive delete --confirm guard (GH-212)", () => {
27042704
assert.ok(del, `must issue DELETE /domains/v1/example.com, calls: ${JSON.stringify(calls)}`);
27052705
});
27062706
});
2707+
2708+
// ── init <rail> --switch-rail guard (GH-210) ────────────────────────────────
2709+
// `run402 init mpp` (or `init` with x402 default) must NOT silently switch the
2710+
// persisted payment rail when the existing allowance is on the other rail.
2711+
// Switching is destructive in the sense that it changes which network the
2712+
// agent's autonomous payments will land on; it must be explicit.
2713+
2714+
describe("CLI init rail-switch guard (GH-210)", () => {
2715+
async function seedAllowance(rail) {
2716+
const { saveAllowance } = await import("./cli/lib/config.mjs");
2717+
saveAllowance({
2718+
address: "0x1234567890123456789012345678901234567890",
2719+
privateKey: "0x" + "11".repeat(32),
2720+
created: "2026-01-01T00:00:00.000Z",
2721+
funded: true,
2722+
rail,
2723+
});
2724+
}
2725+
2726+
async function clearAllowance() {
2727+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2728+
try { rmSync(ALLOWANCE_FILE, { force: true }); } catch {}
2729+
}
2730+
2731+
it("init mpp (no flag) on x402 allowance refuses and leaves rail unchanged", async () => {
2732+
await seedAllowance("x402");
2733+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2734+
const before = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2735+
const { run } = await import("./cli/lib/init.mjs");
2736+
let threw = null;
2737+
captureStart();
2738+
try {
2739+
await run(["mpp"]);
2740+
} catch (e) { threw = e; } finally {
2741+
captureStop();
2742+
}
2743+
assert.equal(threw?.message, "process.exit(1)", "must exit non-zero");
2744+
const stderr = capturedStderr();
2745+
const line = stderr.split("\n").find((s) => s.trim().startsWith("{"));
2746+
assert.ok(line, `expected JSON envelope on stderr, got: ${stderr}`);
2747+
const parsed = JSON.parse(line);
2748+
assert.equal(parsed.status, "error");
2749+
assert.equal(parsed.code, "RAIL_SWITCH_REQUIRES_CONFIRM");
2750+
assert.ok(/--switch-rail/.test(parsed.message), `message should mention --switch-rail, got: ${parsed.message}`);
2751+
assert.equal(parsed.details?.current_rail, "x402");
2752+
assert.equal(parsed.details?.requested_rail, "mpp");
2753+
const after = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2754+
assert.equal(after.rail, before.rail, "allowance.rail must NOT change without --switch-rail");
2755+
assert.equal(after.address, before.address, "allowance.address must not change");
2756+
});
2757+
2758+
it("init mpp --switch-rail on x402 allowance proceeds and updates rail", async () => {
2759+
await seedAllowance("x402");
2760+
const { run } = await import("./cli/lib/init.mjs");
2761+
let threw = null;
2762+
captureStart();
2763+
try {
2764+
await run(["mpp", "--switch-rail"]);
2765+
} catch (e) { threw = e; } finally {
2766+
captureStop();
2767+
}
2768+
assert.equal(threw, null, `should succeed, got: ${threw?.message || ""} / ${capturedStderr()}`);
2769+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2770+
const after = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2771+
assert.equal(after.rail, "mpp", "rail should be updated to mpp");
2772+
const out = captured();
2773+
assert.ok(/Switched from x402/.test(out), `should retain "Switched from x402" UX note, got: ${out}`);
2774+
});
2775+
2776+
it("init x402 on x402 allowance is idempotent (no flag needed)", async () => {
2777+
await seedAllowance("x402");
2778+
const { run } = await import("./cli/lib/init.mjs");
2779+
let threw = null;
2780+
captureStart();
2781+
try {
2782+
await run([]);
2783+
} catch (e) { threw = e; } finally {
2784+
captureStop();
2785+
}
2786+
assert.equal(threw, null, `same-rail re-run should succeed, got: ${threw?.message || ""} / ${capturedStderr()}`);
2787+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2788+
const after = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2789+
assert.equal(after.rail, "x402", "rail should remain x402");
2790+
});
2791+
2792+
it("init mpp on mpp allowance is idempotent (no flag needed)", async () => {
2793+
await seedAllowance("mpp");
2794+
const { run } = await import("./cli/lib/init.mjs");
2795+
let threw = null;
2796+
captureStart();
2797+
try {
2798+
await run(["mpp"]);
2799+
} catch (e) { threw = e; } finally {
2800+
captureStop();
2801+
}
2802+
assert.equal(threw, null, `same-rail re-run should succeed, got: ${threw?.message || ""} / ${capturedStderr()}`);
2803+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2804+
const after = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2805+
assert.equal(after.rail, "mpp", "rail should remain mpp");
2806+
});
2807+
2808+
it("init mpp with no existing allowance succeeds (no rail to switch from)", async () => {
2809+
await clearAllowance();
2810+
const { run } = await import("./cli/lib/init.mjs");
2811+
let threw = null;
2812+
captureStart();
2813+
try {
2814+
await run(["mpp"]);
2815+
} catch (e) { threw = e; } finally {
2816+
captureStop();
2817+
}
2818+
assert.equal(threw, null, `fresh init should succeed, got: ${threw?.message || ""} / ${capturedStderr()}`);
2819+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2820+
const after = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2821+
assert.equal(after.rail, "mpp", "fresh allowance should be created with rail=mpp");
2822+
});
2823+
2824+
it("init x402 (default) on mpp allowance refuses and leaves rail unchanged", async () => {
2825+
await seedAllowance("mpp");
2826+
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
2827+
const before = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2828+
const { run } = await import("./cli/lib/init.mjs");
2829+
let threw = null;
2830+
captureStart();
2831+
try {
2832+
await run([]);
2833+
} catch (e) { threw = e; } finally {
2834+
captureStop();
2835+
}
2836+
assert.equal(threw?.message, "process.exit(1)", "must exit non-zero");
2837+
const stderr = capturedStderr();
2838+
const line = stderr.split("\n").find((s) => s.trim().startsWith("{"));
2839+
assert.ok(line, `expected JSON envelope on stderr, got: ${stderr}`);
2840+
const parsed = JSON.parse(line);
2841+
assert.equal(parsed.code, "RAIL_SWITCH_REQUIRES_CONFIRM");
2842+
assert.equal(parsed.details?.current_rail, "mpp");
2843+
assert.equal(parsed.details?.requested_rail, "x402");
2844+
const after = JSON.parse(readFileSync(ALLOWANCE_FILE, "utf8"));
2845+
assert.equal(after.rail, before.rail, "allowance.rail must NOT change without --switch-rail");
2846+
});
2847+
});

cli-provision-active.test.mjs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* cli-provision-active.test.mjs — GH-183 regression.
3+
*
4+
* `run402 projects provision` silently overwrites the active project pointer.
5+
* The CLI must surface this in its JSON output as `note` + `previous_active_project_id`
6+
* so callers can branch on it without scraping logs.
7+
*/
8+
9+
import { describe, it, before, after, beforeEach } from "node:test";
10+
import assert from "node:assert/strict";
11+
import { mkdtempSync, rmSync } from "node:fs";
12+
import { join } from "node:path";
13+
import { tmpdir } from "node:os";
14+
15+
const tempDir = mkdtempSync(join(tmpdir(), "run402-provision-active-"));
16+
const API = "https://test-api.run402.com";
17+
process.env.RUN402_CONFIG_DIR = tempDir;
18+
process.env.RUN402_API_BASE = API;
19+
20+
const originalFetch = globalThis.fetch;
21+
const originalLog = console.log;
22+
const originalError = console.error;
23+
const originalExit = process.exit;
24+
let stdoutLines = [];
25+
let stderrLines = [];
26+
27+
let nextProjectId = "prj_fresh";
28+
29+
function json(data, status = 200) {
30+
return new Response(JSON.stringify(data), {
31+
status,
32+
headers: { "Content-Type": "application/json" },
33+
});
34+
}
35+
36+
const USDC_BALANCE_HEX = "0x" + "0".repeat(58) + "03d090";
37+
38+
function mockFetch(input, init) {
39+
let url, method, rawBody;
40+
if (typeof input === "string") {
41+
url = input;
42+
method = (init?.method || "GET").toUpperCase();
43+
rawBody = init?.body;
44+
} else if (input instanceof Request) {
45+
url = input.url;
46+
method = (init?.method || input.method || "GET").toUpperCase();
47+
rawBody = init?.body !== undefined ? init.body : undefined;
48+
} else {
49+
url = String(input);
50+
method = (init?.method || "GET").toUpperCase();
51+
rawBody = init?.body;
52+
}
53+
let body = null;
54+
if (rawBody && typeof rawBody === "string") {
55+
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
56+
} else if (rawBody) {
57+
body = rawBody;
58+
}
59+
60+
if (body?.jsonrpc === "2.0") {
61+
if (body.method === "eth_call") {
62+
return Promise.resolve(json({ jsonrpc: "2.0", result: USDC_BALANCE_HEX, id: body.id }));
63+
}
64+
if (body.method === "eth_chainId") {
65+
return Promise.resolve(json({ jsonrpc: "2.0", result: "0x14a34", id: body.id }));
66+
}
67+
return Promise.resolve(json({ jsonrpc: "2.0", result: "0x0", id: body.id }));
68+
}
69+
70+
const allowedOrigins = new Set([new URL(API).origin, "https://api.run402.com"]);
71+
let path = url;
72+
try {
73+
const parsed = new URL(url);
74+
if (allowedOrigins.has(parsed.origin)) {
75+
path = parsed.pathname + parsed.search;
76+
} else {
77+
return Promise.resolve(new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }));
78+
}
79+
} catch {
80+
if (!url.startsWith("/")) {
81+
return Promise.resolve(new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }));
82+
}
83+
}
84+
85+
if (path === "/projects/v1" && method === "POST") {
86+
return Promise.resolve(json({
87+
project_id: nextProjectId,
88+
anon_key: `anon-${nextProjectId}`,
89+
service_key: `svc-${nextProjectId}`,
90+
schema_slot: "p0001",
91+
}));
92+
}
93+
if (path.startsWith("/tiers/v1/") && method === "GET") {
94+
return Promise.resolve(json({ price: "$0.10", network: "base-sepolia" }));
95+
}
96+
return Promise.resolve(new Response("Not Found", { status: 404 }));
97+
}
98+
99+
function captureStart() {
100+
stdoutLines = [];
101+
stderrLines = [];
102+
console.log = (...args) => {
103+
const line = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
104+
stdoutLines.push(line);
105+
};
106+
console.error = (...args) => {
107+
const line = args.map(a => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
108+
stderrLines.push(line);
109+
};
110+
}
111+
112+
function captureStop() {
113+
console.log = originalLog;
114+
console.error = originalError;
115+
}
116+
117+
function capturedStdout() {
118+
return stdoutLines.join("\n");
119+
}
120+
121+
function parseStdoutJson() {
122+
const text = capturedStdout();
123+
const start = text.indexOf("{");
124+
const end = text.lastIndexOf("}");
125+
if (start < 0 || end < 0) return null;
126+
try { return JSON.parse(text.slice(start, end + 1)); } catch { return null; }
127+
}
128+
129+
before(async () => {
130+
globalThis.fetch = mockFetch;
131+
process.exit = (code) => { throw new Error(`process.exit(${code})`); };
132+
const { run } = await import("./cli/lib/allowance.mjs");
133+
captureStart();
134+
await run("create", []);
135+
captureStop();
136+
});
137+
138+
after(async () => {
139+
globalThis.fetch = originalFetch;
140+
console.log = originalLog;
141+
console.error = originalError;
142+
process.exit = originalExit;
143+
delete process.env.RUN402_CONFIG_DIR;
144+
delete process.env.RUN402_API_BASE;
145+
rmSync(tempDir, { recursive: true, force: true });
146+
});
147+
148+
beforeEach(() => {
149+
captureStop();
150+
});
151+
152+
describe("CLI projects provision active-project banner (GH-183)", () => {
153+
it("first provision: no note when there was no prior active project", async () => {
154+
nextProjectId = "prj_first";
155+
const { run } = await import("./cli/lib/projects.mjs");
156+
captureStart();
157+
await run("provision", ["--tier", "prototype"]);
158+
captureStop();
159+
160+
const parsed = parseStdoutJson();
161+
assert.ok(parsed, `expected JSON output, got: ${capturedStdout()}`);
162+
assert.equal(parsed.project_id, "prj_first");
163+
assert.equal(parsed.note, undefined, "no note expected on first provision");
164+
assert.equal(parsed.previous_active_project_id, undefined);
165+
});
166+
167+
it("subsequent provision: emits note + previous_active_project_id when active project changes", async () => {
168+
nextProjectId = "prj_second";
169+
const { run } = await import("./cli/lib/projects.mjs");
170+
captureStart();
171+
await run("provision", ["--tier", "prototype"]);
172+
captureStop();
173+
174+
const parsed = parseStdoutJson();
175+
assert.ok(parsed, `expected JSON output, got: ${capturedStdout()}`);
176+
assert.equal(parsed.project_id, "prj_second");
177+
assert.equal(
178+
parsed.note,
179+
"active project changed: prj_first -> prj_second",
180+
`expected change note, got: ${parsed.note}`,
181+
);
182+
assert.equal(parsed.previous_active_project_id, "prj_first");
183+
});
184+
185+
it("subsequent provision keystore reflects previous_active_project_id for safe revert", async () => {
186+
const { loadKeyStore } = await import("./cli/lib/config.mjs");
187+
const store = loadKeyStore();
188+
assert.equal(store.active_project_id, "prj_second");
189+
assert.equal(store.previous_active_project_id, "prj_first");
190+
});
191+
});

0 commit comments

Comments
 (0)