callx402 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/npm-publish.yml.staged +27 -0
- package/.github/workflows/npm-publish.yml +29 -0
- package/.github/workflows/review-ci.yml +24 -0
- package/README.md +171 -10
- package/bin/callx402.js +300 -13
- package/core/index.js +35 -6
- package/core/payer-auth.js +59 -0
- package/core/rail.js +317 -0
- package/core/veyline.js +240 -0
- package/docs/README.md +11 -9
- package/docs/action-reference.md +2 -2
- package/docs/cli-help-verified-2026-10-06.txt +36 -0
- package/docs/problem-map.md +30 -15
- package/docs/signed-redemption.md +84 -0
- package/docs/troubleshooting/cx402-duppay-001-duplicate-payment-risk.md +135 -0
- package/docs/troubleshooting/cx402-paidnores-001-paid-no-result.md +3 -1
- package/docs/troubleshooting/cx402-safretry-001-retry-after-payment.md +13 -1
- package/docs/what-is-callx402.md +5 -4
- package/logo-400.png +0 -0
- package/mcp/README.md +5 -0
- package/mcp/index.js +1 -1
- package/package.json +4 -3
- package/server.json +2 -2
- package/test/cli-dispatch.test.js +38 -19
- package/test/helpers.js +20 -0
- package/test/integrity.test.js +2 -1
- package/test/intent-pipeline.test.js +2 -3
- package/test/payer-auth.test.js +92 -0
- package/test/remote.test.js +2 -3
- package/test/safety.test.js +4 -5
- package/test/sdk.test.js +3 -4
- package/test/server.test.js +4 -5
- package/test/subsystems.test.js +7 -8
- package/test/veyline-commands.test.js +193 -0
- package/mcp-publisher +0 -0
package/core/rail.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/rail.js — lightweight client for the hosted Payload Rail.
|
|
5
|
+
*
|
|
6
|
+
* When the v2.0.0 runtime tree is not available locally (or unless
|
|
7
|
+
* CALLX402_LOCAL=1), subsystem commands route here automatically:
|
|
8
|
+
*
|
|
9
|
+
* invoke -> free quote -> user approves -> payment/authorization -> result
|
|
10
|
+
*
|
|
11
|
+
* Payment paths (no wallet required in the CLI):
|
|
12
|
+
* - x402: user pays USDC on Base from their own wallet, pastes the tx hash.
|
|
13
|
+
* - card: CLI opens the Stripe checkout in a browser; user pastes the
|
|
14
|
+
* single-use credit ID from the success page.
|
|
15
|
+
*
|
|
16
|
+
* The rail verifies payment exactly once, executes read-only diagnostic
|
|
17
|
+
* actions server-side, meters the invocation, and returns the result.
|
|
18
|
+
* Nothing here signs, holds keys, or moves money.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { randomBytes } = require('node:crypto');
|
|
22
|
+
const { buildAuthMessage, actionRequestHash, readSignedAuthorization } = require('./payer-auth.js');
|
|
23
|
+
const RAIL_BASE = process.env.CALLX402_RAIL || 'https://payload-rail.fly.dev';
|
|
24
|
+
const RAIL_ACTIONS = [
|
|
25
|
+
'diagnose', 'doctor', 'resolve', 'preflight',
|
|
26
|
+
'evidence', 'explain', 'recover',
|
|
27
|
+
'settlement_interpretation', 'failure_classification', 'duplicate_payment_risk',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// Map CLI command names to rail action names.
|
|
31
|
+
const ACTION_ALIASES = { doctor: 'diagnose' };
|
|
32
|
+
|
|
33
|
+
function railActionFor(command) {
|
|
34
|
+
return ACTION_ALIASES[command] || command;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRailCommand(command) {
|
|
38
|
+
return RAIL_ACTIONS.includes(command);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when the caller explicitly prefers the local v2 tree. */
|
|
42
|
+
function preferLocal() {
|
|
43
|
+
return process.env.CALLX402_LOCAL === '1';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function railFetch(path, opts = {}) {
|
|
47
|
+
const res = await fetch(`${RAIL_BASE}${path}`, {
|
|
48
|
+
...opts,
|
|
49
|
+
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
50
|
+
});
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
let body = null;
|
|
53
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = { _raw: text }; }
|
|
54
|
+
return { status: res.status, body };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getQuote(action) {
|
|
58
|
+
const { status, body } = await railFetch(
|
|
59
|
+
`/v1/callx402/quote?action=${encodeURIComponent(action)}&path=x402`,
|
|
60
|
+
);
|
|
61
|
+
if (status !== 200 || !body || !body.quoted_price_usd) {
|
|
62
|
+
throw new Error(`quote unavailable (HTTP ${status})`);
|
|
63
|
+
}
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function prompt(question) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const rl = require('node:readline').createInterface({
|
|
70
|
+
input: process.stdin,
|
|
71
|
+
output: process.stdout,
|
|
72
|
+
});
|
|
73
|
+
rl.question(question, (answer) => {
|
|
74
|
+
rl.close();
|
|
75
|
+
resolve(answer.trim());
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openBrowser(url) {
|
|
81
|
+
const { execSync } = require('node:child_process');
|
|
82
|
+
const platform = process.platform;
|
|
83
|
+
try {
|
|
84
|
+
if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
85
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
86
|
+
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildEvidence(action, args) {
|
|
94
|
+
// Prefer explicit --evidence JSON (or @file); otherwise assemble from args.
|
|
95
|
+
if (args.evidence) {
|
|
96
|
+
const raw = String(args.evidence);
|
|
97
|
+
if (raw.startsWith('@')) {
|
|
98
|
+
const fs = require('node:fs');
|
|
99
|
+
const path = require('node:path');
|
|
100
|
+
return fs.readFileSync(path.resolve(raw.slice(1)), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
return raw;
|
|
103
|
+
}
|
|
104
|
+
const ev = {};
|
|
105
|
+
if (args.target) ev.target = args.target;
|
|
106
|
+
if (args.incident) ev.incident = args.incident;
|
|
107
|
+
if (args.goal) ev.goal = args.goal;
|
|
108
|
+
if (args.intent) ev.intent = args.intent;
|
|
109
|
+
if (args.query) ev.query = args.query;
|
|
110
|
+
if (args.operationId) ev.operationId = args.operationId;
|
|
111
|
+
if (args.identity) ev.identity = args.identity;
|
|
112
|
+
return ev;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Invoke a paid action via the hosted rail.
|
|
117
|
+
* Returns { ok, result } for display by the caller.
|
|
118
|
+
*/
|
|
119
|
+
async function invokeRail(command, args, opts = {}) {
|
|
120
|
+
const action = railActionFor(command);
|
|
121
|
+
const evidence = buildEvidence(action, args);
|
|
122
|
+
const interactive = process.stdin.isTTY && !opts.yes;
|
|
123
|
+
|
|
124
|
+
// 1. Fresh quote for new actions; previously paid agent redemptions
|
|
125
|
+
// must reuse their ORIGINAL signed quote. A fresh quote has a different
|
|
126
|
+
// issuedAt/quote_id and would invalidate an otherwise authentic signature.
|
|
127
|
+
let quote;
|
|
128
|
+
let signedAuthorization = null;
|
|
129
|
+
try {
|
|
130
|
+
if (args.txHash && args.payerAuth) {
|
|
131
|
+
signedAuthorization = readSignedAuthorization(String(args.payerAuth));
|
|
132
|
+
if (!signedAuthorization.quote_inputs || typeof signedAuthorization.quote_inputs !== 'object') {
|
|
133
|
+
throw new Error('Signed authorization file must include the canonical quote_inputs from the original quote.');
|
|
134
|
+
}
|
|
135
|
+
quote = {
|
|
136
|
+
quote_id: signedAuthorization.quote_id,
|
|
137
|
+
quote_inputs: signedAuthorization.quote_inputs,
|
|
138
|
+
quoted_price_usd: signedAuthorization.quoted_price_usd ?? 'see signed quote',
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
quote = await getQuote(action);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { ok: false, error: `Unable to load a valid payment quote: ${err.message}` };
|
|
145
|
+
}
|
|
146
|
+
const price = quote.quoted_price_usd;
|
|
147
|
+
const quoteId = quote.quote_id;
|
|
148
|
+
|
|
149
|
+
if (!opts.json) {
|
|
150
|
+
console.log(`callx402 (hosted) — action '${action}'`);
|
|
151
|
+
console.log(` Free quote: $${price} (quote ${String(quoteId).slice(0, 8)}…)`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 2. User approves the spend.
|
|
155
|
+
if (!opts.yes) {
|
|
156
|
+
if (!interactive) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
error: `Payment of $${price} required. Re-run with --yes to approve non-interactively, or run interactively.`,
|
|
160
|
+
quote: { action, price_usd: price, quote_id: quoteId, quote_inputs: quote.quote_inputs },
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const approve = await prompt(` Approve $${price} for '${action}'? [y/N] `);
|
|
164
|
+
if (!/^y(es)?$/i.test(approve)) {
|
|
165
|
+
return { ok: false, error: 'Cancelled by user before payment.' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Payment: x402 (paste tx hash) or card (browser checkout -> credit ID).
|
|
170
|
+
let payBody = { evidence, quote_id: quoteId, quote: quote.quote_inputs || {} };
|
|
171
|
+
if ((action === 'evidence' || action === 'explain') && args.operationId) {
|
|
172
|
+
payBody.invocation_id = String(args.operationId);
|
|
173
|
+
}
|
|
174
|
+
// Agents can supply a wallet-produced EIP-191 signature through a file;
|
|
175
|
+
// private keys are never requested or handled by callx402.
|
|
176
|
+
if (args.creditId) {
|
|
177
|
+
payBody = { ...payBody, credit_id: String(args.creditId) };
|
|
178
|
+
} else if (args.txHash && args.payerAuth) {
|
|
179
|
+
if (!quote.quote_inputs) return { ok: false, error: 'Rail must return canonical quote_inputs before signed redemption.' };
|
|
180
|
+
const auth = signedAuthorization;
|
|
181
|
+
if (auth.txHash?.toLowerCase() !== String(args.txHash).toLowerCase() ||
|
|
182
|
+
auth.action !== action || auth.quote_id !== quoteId ||
|
|
183
|
+
auth.request_hash !== actionRequestHash(action, payBody)) {
|
|
184
|
+
return { ok: false, error: 'Signed authorization does not match the transaction, action, and current quote.' };
|
|
185
|
+
}
|
|
186
|
+
payBody = { ...payBody, txHash: String(args.txHash).toLowerCase(), payer_auth: auth };
|
|
187
|
+
}
|
|
188
|
+
if (!payBody.credit_id && !payBody.payer_auth && !interactive) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: 'Non-interactive payment requires --credit-id or --tx-hash with --payer-auth @file.',
|
|
192
|
+
quote: { action, price_usd: price, quote_id: quoteId },
|
|
193
|
+
payment_instructions: `Use --credit-id or --tx-hash plus --payer-auth @signed-auth.json after an authorized wallet signature; a bare txHash is never enough.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (!payBody.credit_id && !payBody.payer_auth) {
|
|
197
|
+
const method = await prompt(' Pay with [1] USDC on Base (paste tx hash after paying) or [2] card via browser? [1/2] ');
|
|
198
|
+
if (method === '2') {
|
|
199
|
+
// Stripe is a distinct price path; never imply its fee equals the
|
|
200
|
+
// previously displayed micropayment quote.
|
|
201
|
+
let cardPrice;
|
|
202
|
+
try {
|
|
203
|
+
const catalog = await railFetch('/v1/callx402/actions');
|
|
204
|
+
const item = catalog.body?.actions?.find(a => a.action === action);
|
|
205
|
+
if (catalog.status !== 200 || !Number.isSafeInteger(item?.amount_cents) || item.amount_cents <= 0) {
|
|
206
|
+
throw new Error('card fee not available');
|
|
207
|
+
}
|
|
208
|
+
cardPrice = (item.amount_cents / 100).toFixed(2);
|
|
209
|
+
} catch {
|
|
210
|
+
return { ok: false, error: 'Could not confirm the exact card price. Checkout cancelled before payment.' };
|
|
211
|
+
}
|
|
212
|
+
const cardApproval = await prompt(` Card checkout total $ ${cardPrice} USD for '${action}'. Continue? [y/N] `);
|
|
213
|
+
if (!/^y(es)?$/i.test(cardApproval)) {
|
|
214
|
+
return { ok: false, error: 'Card checkout cancelled before payment.' };
|
|
215
|
+
}
|
|
216
|
+
// Stripe checkout path.
|
|
217
|
+
const { status, body } = await railFetch('/v1/callx402/checkout', {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: JSON.stringify({ action }),
|
|
220
|
+
});
|
|
221
|
+
if (status !== 200 && status !== 201) {
|
|
222
|
+
return { ok: false, error: `Checkout creation failed (HTTP ${status}).` };
|
|
223
|
+
}
|
|
224
|
+
const url = body.checkout_url || body.url;
|
|
225
|
+
if (!url) return { ok: false, error: 'Checkout URL not returned by rail.' };
|
|
226
|
+
console.log(` Opening checkout in your browser…`);
|
|
227
|
+
if (!openBrowser(url)) console.log(` Open this URL manually: ${url}`);
|
|
228
|
+
console.log(' Complete the payment, then paste the single-use credit ID from the success page.');
|
|
229
|
+
const creditId = await prompt(' Credit ID: ');
|
|
230
|
+
if (!creditId) return { ok: false, error: 'No credit ID provided.' };
|
|
231
|
+
payBody = { ...payBody, credit_id: creditId };
|
|
232
|
+
} else {
|
|
233
|
+
if (!quote.quote_inputs) return { ok: false, error: 'Rail must return canonical quote_inputs before USDC payment.' };
|
|
234
|
+
// x402 path: get exact payment terms from the 402, then user pays.
|
|
235
|
+
const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
|
|
236
|
+
method: 'POST',
|
|
237
|
+
body: JSON.stringify({ evidence, quote_id: quoteId, quote: quote.quote_inputs }),
|
|
238
|
+
});
|
|
239
|
+
if (status !== 402) {
|
|
240
|
+
return { ok: false, error: `Expected a 402 payment challenge, got HTTP ${status}.` };
|
|
241
|
+
}
|
|
242
|
+
const accept = (body.accepts && body.accepts[0]) || {};
|
|
243
|
+
const amountUsdc = accept.amount ? (Number(accept.amount) / 1e6).toFixed(2) : price;
|
|
244
|
+
console.log(` Send ${amountUsdc} USDC on Base (eip155:8453) to:`);
|
|
245
|
+
console.log(` ${accept.payTo || '(see 402 response)'}`);
|
|
246
|
+
console.log(' Then paste the transaction hash:');
|
|
247
|
+
const txHash = await prompt(' Tx hash: ');
|
|
248
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
|
|
249
|
+
return { ok: false, error: 'Invalid transaction hash format.' };
|
|
250
|
+
}
|
|
251
|
+
const wallet = await prompt(' Payer wallet address (0x...): ');
|
|
252
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(wallet)) {
|
|
253
|
+
return { ok: false, error: 'A valid Ethereum payer wallet address is required.' };
|
|
254
|
+
}
|
|
255
|
+
const auth = {
|
|
256
|
+
wallet, action, txHash: txHash.toLowerCase(), quote_id: quoteId,
|
|
257
|
+
request_hash: actionRequestHash(action, payBody),
|
|
258
|
+
network: accept.network || 'eip155:8453',
|
|
259
|
+
recipient: accept.payTo,
|
|
260
|
+
nonce: '0x' + randomBytes(16).toString('hex'),
|
|
261
|
+
expiry: Math.floor(Date.now() / 1000) + 300,
|
|
262
|
+
};
|
|
263
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(auth.recipient || '')) {
|
|
264
|
+
return { ok: false, error: 'The payment challenge omitted a valid recipient; refusing redemption.' };
|
|
265
|
+
}
|
|
266
|
+
const signingMessage = buildAuthMessage(auth);
|
|
267
|
+
console.log('\n Sign the exact message below using your paying wallet (EIP-191 personal_sign).');
|
|
268
|
+
console.log(' NEVER paste a seed phrase, private key, or recovery code.\n');
|
|
269
|
+
console.log(signingMessage);
|
|
270
|
+
console.log();
|
|
271
|
+
const signature = await prompt(' Paste wallet signature (0x...): ');
|
|
272
|
+
if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) {
|
|
273
|
+
return { ok: false, error: 'Expected a 65-byte Ethereum personal_sign signature.' };
|
|
274
|
+
}
|
|
275
|
+
payBody = { ...payBody, txHash: txHash.toLowerCase(),
|
|
276
|
+
payer_auth: { ...auth, signature } };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 4. Submit payment proof -> verified, executed, metered -> result.
|
|
281
|
+
const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
body: JSON.stringify(payBody),
|
|
284
|
+
});
|
|
285
|
+
if (status === 402) {
|
|
286
|
+
return { ok: false, error: 'Payment not yet recognized. The quote may have expired — re-run to get a fresh quote.' };
|
|
287
|
+
}
|
|
288
|
+
if (status === 401) {
|
|
289
|
+
return { ok: false, error: `Wallet authorization rejected: ${body?.error?.message || 'check signed message and wallet'}` };
|
|
290
|
+
}
|
|
291
|
+
if (status === 422) {
|
|
292
|
+
return { ok: false, error: `Action could not execute: ${body?.error?.message || body?.execution?.error || 'not available'}` };
|
|
293
|
+
}
|
|
294
|
+
if (status === 409) {
|
|
295
|
+
return { ok: false, error: `Already used: ${body?.error?.message || 'this payment was already consumed'}.` };
|
|
296
|
+
}
|
|
297
|
+
if (status !== 200 || !body || !body.ok) {
|
|
298
|
+
return { ok: false, error: `Rail error (HTTP ${status}): ${body?.error?.message || 'unknown'}` };
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
action,
|
|
303
|
+
invocation_id: body.invocation_id,
|
|
304
|
+
via: body.via,
|
|
305
|
+
execution: body.execution || null,
|
|
306
|
+
notice: body.notice,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
RAIL_BASE,
|
|
312
|
+
isRailCommand,
|
|
313
|
+
preferLocal,
|
|
314
|
+
railActionFor,
|
|
315
|
+
invokeRail,
|
|
316
|
+
getQuote,
|
|
317
|
+
};
|
package/core/veyline.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/veyline.js — dispatch handlers for the Veyline Recovery track:
|
|
5
|
+
* callx402 evidence <operationId> [--dir <path>] [--evidence <json|@file>]
|
|
6
|
+
* callx402 explain <operationId> [--dir <path>] [--evidence <json|@file>]
|
|
7
|
+
* callx402 recover <operationId> [--dir <path>] [--evidence <json|@file>]
|
|
8
|
+
* [--identity <id>]
|
|
9
|
+
*
|
|
10
|
+
* ADDITIVE-ONLY: this file is new; no existing command is touched. It
|
|
11
|
+
* resolves the Veyline recovery modules from the recovery dir
|
|
12
|
+
* (CALLX402_VEYLINE_ROOT env, config veylineRoot, or the default relative
|
|
13
|
+
* path) — it does NOT go through core/subsystems.js, whose hard rule
|
|
14
|
+
* limits requires to the v2.0.0 tree.
|
|
15
|
+
*
|
|
16
|
+
* All three commands are read-only. `recover` reports the recovery
|
|
17
|
+
* decision the network would make (RECOVERABLE / SAFE_RETRY /
|
|
18
|
+
* EXECUTED_BUT_UNRECOVERABLE / SETTLEMENT_UNKNOWN); it never charges,
|
|
19
|
+
* never executes, never repays. Performing the approved safe action stays
|
|
20
|
+
* with the operator's own adapters.
|
|
21
|
+
*
|
|
22
|
+
* Gate: PAYLOAD_VEYLINE_LEDGER=1 for evidence/explain,
|
|
23
|
+
* PAYLOAD_VEYLINE_RECOVERY=1 for recover. Default OFF.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { EXIT, ok, fail } = require('./envelope');
|
|
28
|
+
|
|
29
|
+
function veylineRoot(config = {}) {
|
|
30
|
+
return config.veylineRoot
|
|
31
|
+
|| process.env.CALLX402_VEYLINE_ROOT
|
|
32
|
+
|| path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadVeyline(config, deps) {
|
|
36
|
+
if (deps && deps.veyline) {
|
|
37
|
+
return { modules: deps.veyline, flag: '(test override)', overridden: true };
|
|
38
|
+
}
|
|
39
|
+
const root = veylineRoot(config);
|
|
40
|
+
try {
|
|
41
|
+
const modules = {
|
|
42
|
+
ledger: require(path.join(root, 'operation-ledger.js')),
|
|
43
|
+
recovery: require(path.join(root, 'recovery-state.js')),
|
|
44
|
+
planner: require(path.join(root, 'rescue-planner.js')),
|
|
45
|
+
trust: require(path.join(root, 'trust-graph.js')),
|
|
46
|
+
intel: require(path.join(root, 'incident-intel.js')),
|
|
47
|
+
continuity: require(path.join(root, 'continuity.js')),
|
|
48
|
+
root,
|
|
49
|
+
};
|
|
50
|
+
return { modules, flag: null, overridden: false };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { modules: null, error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJsonInput(input) {
|
|
57
|
+
if (input === undefined || input === null || input === '') return {};
|
|
58
|
+
if (typeof input !== 'string') return input;
|
|
59
|
+
const s = input.trim();
|
|
60
|
+
if (s.startsWith('@')) {
|
|
61
|
+
const fs = require('fs');
|
|
62
|
+
return JSON.parse(fs.readFileSync(path.resolve(s.slice(1)), 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
return JSON.parse(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function disabledEnvelope(action, flag) {
|
|
68
|
+
return {
|
|
69
|
+
result: fail(action, {
|
|
70
|
+
subsystem: 'veyline', subsystemStatus: 'disabled',
|
|
71
|
+
code: 'subsystem_disabled',
|
|
72
|
+
message: `veyline recovery subsystem is disabled. Set ${flag}=1 to enable it. Nothing was executed.`,
|
|
73
|
+
disposition: 'veyline disabled: nothing executed.',
|
|
74
|
+
data: { flag },
|
|
75
|
+
}),
|
|
76
|
+
exitCode: EXIT.UNAVAILABLE,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gate(modules, action, flag) {
|
|
81
|
+
if (!modules) {
|
|
82
|
+
return {
|
|
83
|
+
result: fail(action, { subsystem: 'veyline', code: 'subsystem_unreachable', message: 'veyline recovery modules could not be loaded. Nothing was executed.' }),
|
|
84
|
+
exitCode: EXIT.UNAVAILABLE,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const mod = action === 'recover' ? modules.recovery : modules.ledger;
|
|
88
|
+
const on = typeof mod.enabled === 'function' ? mod.enabled() : false;
|
|
89
|
+
if (!on) return disabledEnvelope(action, flag);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ledgerFor(modules, dir) {
|
|
94
|
+
const d = dir || path.join(process.cwd(), '.veyline');
|
|
95
|
+
return modules.ledger.createOperationLedger({ dir: d });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function actEvidence(args, ctx) {
|
|
99
|
+
const { modules, error } = loadVeyline(ctx.config, ctx.deps);
|
|
100
|
+
if (error || !modules) {
|
|
101
|
+
return { result: fail('evidence', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
|
|
102
|
+
}
|
|
103
|
+
const g = gate(modules, 'evidence', modules.ledger.FLAG);
|
|
104
|
+
if (g) return g;
|
|
105
|
+
try {
|
|
106
|
+
const operationId = args.operationId;
|
|
107
|
+
if (!operationId) {
|
|
108
|
+
return { result: fail('evidence', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 evidence <operationId>' }), exitCode: EXIT.USAGE };
|
|
109
|
+
}
|
|
110
|
+
const ledger = ledgerFor(modules, args.dir);
|
|
111
|
+
const events = ledger.events(operationId);
|
|
112
|
+
const states = ledger.latestStates(operationId);
|
|
113
|
+
const protocols = ledger.protocols(operationId);
|
|
114
|
+
const data = {
|
|
115
|
+
operationId, events, states, protocols,
|
|
116
|
+
eventCount: events.length,
|
|
117
|
+
basis: events.length > 0 ? 'recorded ledger evidence' : 'no_basis: no events recorded for this operation',
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
result: ok('evidence', {
|
|
121
|
+
subsystem: 'veyline',
|
|
122
|
+
disposition: events.length > 0
|
|
123
|
+
? `Evidence for ${operationId}: ${events.length} event(s) across ${protocols.join(', ') || 'no protocols'}.`
|
|
124
|
+
: `No evidence recorded for ${operationId}.`,
|
|
125
|
+
data,
|
|
126
|
+
}),
|
|
127
|
+
exitCode: EXIT.OK,
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const isMalformed = err instanceof SyntaxError;
|
|
131
|
+
return { result: fail('evidence', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function actExplain(args, ctx) {
|
|
136
|
+
const { modules, error } = loadVeyline(ctx.config, ctx.deps);
|
|
137
|
+
if (error || !modules) {
|
|
138
|
+
return { result: fail('explain', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
|
|
139
|
+
}
|
|
140
|
+
const g = gate(modules, 'explain', modules.ledger.FLAG);
|
|
141
|
+
if (g) return g;
|
|
142
|
+
try {
|
|
143
|
+
const operationId = args.operationId;
|
|
144
|
+
if (!operationId) {
|
|
145
|
+
return { result: fail('explain', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 explain <operationId>' }), exitCode: EXIT.USAGE };
|
|
146
|
+
}
|
|
147
|
+
const ledger = ledgerFor(modules, args.dir);
|
|
148
|
+
const events = ledger.events(operationId);
|
|
149
|
+
const states = ledger.latestStates(operationId);
|
|
150
|
+
let assessment;
|
|
151
|
+
if (events.length === 0) {
|
|
152
|
+
assessment = 'NO_BASIS: nothing recorded; no claim can be made about this operation.';
|
|
153
|
+
} else if (states.payment === 'UNKNOWN' || states.execution === 'UNKNOWN') {
|
|
154
|
+
assessment = 'INCOMPLETE: a plane is UNKNOWN — do not retry, do not repay; gather fresh evidence or escalate to human review.';
|
|
155
|
+
} else if (states.payment === 'SETTLED' && states.execution === 'SUCCEEDED' && (states.delivery === 'DELIVERED' || states.delivery === 'ACKED')) {
|
|
156
|
+
assessment = 'KNOWN_SAFE: paid, executed, delivered.';
|
|
157
|
+
} else if (states.delivery === 'LOST') {
|
|
158
|
+
assessment = 'RECOVERY_CANDIDATE: delivery lost — run `callx402 recover <operationId>` to see the safe recovery decision.';
|
|
159
|
+
} else {
|
|
160
|
+
assessment = 'PARTIAL: some planes resolved, some not — see states; safe action depends on the unresolved plane.';
|
|
161
|
+
}
|
|
162
|
+
const trail = events.map((e) => `[${e.seq}] ${e.at} ${e.kind}${e.protocol ? `/${e.protocol}` : ''}: ${e.summary || ''}`.trim());
|
|
163
|
+
return {
|
|
164
|
+
result: ok('explain', {
|
|
165
|
+
subsystem: 'veyline',
|
|
166
|
+
disposition: `Explanation for ${operationId}: ${assessment}`,
|
|
167
|
+
data: { operationId, states, assessment, trail, eventCount: events.length },
|
|
168
|
+
}),
|
|
169
|
+
exitCode: EXIT.OK,
|
|
170
|
+
};
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const isMalformed = err instanceof SyntaxError;
|
|
173
|
+
return { result: fail('explain', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function actRecover(args, ctx) {
|
|
178
|
+
const { modules, error } = loadVeyline(ctx.config, ctx.deps);
|
|
179
|
+
if (error || !modules) {
|
|
180
|
+
return { result: fail('recover', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
|
|
181
|
+
}
|
|
182
|
+
const g = gate(modules, 'recover', modules.recovery.FLAG);
|
|
183
|
+
if (g) return g;
|
|
184
|
+
try {
|
|
185
|
+
const operationId = args.operationId;
|
|
186
|
+
if (!operationId) {
|
|
187
|
+
return { result: fail('recover', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 recover <operationId>' }), exitCode: EXIT.USAGE };
|
|
188
|
+
}
|
|
189
|
+
const evidence = readJsonInput(args.evidence);
|
|
190
|
+
const dir = args.dir || path.join(process.cwd(), '.veyline');
|
|
191
|
+
const ledger = ledgerFor(modules, args.dir);
|
|
192
|
+
// File-backed cache shared via --dir: the CLI reads the same recovery
|
|
193
|
+
// substrate a server wrote. Without --dir the cache is in-memory.
|
|
194
|
+
const cache = args.dir && typeof modules.recovery.createFileBackedCache === 'function'
|
|
195
|
+
? modules.recovery.createFileBackedCache({ file: path.join(dir, 'recovery-cache.jsonl') })
|
|
196
|
+
: undefined;
|
|
197
|
+
const network = modules.recovery.createRecoveryNetwork({ ledger, cache });
|
|
198
|
+
let identity = args.identity || evidence.identity || null;
|
|
199
|
+
if (!identity && evidence.toolName && evidence.payer && evidence.paymentAuthFingerprint) {
|
|
200
|
+
identity = network.buildOperationIdentity({
|
|
201
|
+
toolName: evidence.toolName,
|
|
202
|
+
args: evidence.args,
|
|
203
|
+
payer: evidence.payer,
|
|
204
|
+
paymentAuthFingerprint: evidence.paymentAuthFingerprint,
|
|
205
|
+
traceId: evidence.traceId,
|
|
206
|
+
operationId,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (!identity) {
|
|
210
|
+
return {
|
|
211
|
+
result: fail('recover', {
|
|
212
|
+
subsystem: 'veyline', code: 'usage',
|
|
213
|
+
message: 'an operation identity is required: pass --identity <id> or --evidence with toolName/payer/paymentAuthFingerprint (+args/traceId).',
|
|
214
|
+
}),
|
|
215
|
+
exitCode: EXIT.USAGE,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const decision = network.onLostResult({
|
|
219
|
+
operationId,
|
|
220
|
+
identity,
|
|
221
|
+
credentials: { payer: evidence.payer, paymentAuthFingerprint: evidence.paymentAuthFingerprint },
|
|
222
|
+
evidence,
|
|
223
|
+
});
|
|
224
|
+
const terminal = decision.recoveryState === 'RECOVERABLE' || decision.recoveryState === 'SAFE_RETRY'
|
|
225
|
+
? 'KNOWN_SAFE' : 'HUMAN_REVIEW';
|
|
226
|
+
return {
|
|
227
|
+
result: ok('recover', {
|
|
228
|
+
subsystem: 'veyline',
|
|
229
|
+
disposition: `Recovery decision for ${operationId}: ${decision.recoveryState} -> ${terminal}. Read-only: nothing charged, nothing executed.`,
|
|
230
|
+
data: { operationId, decision, terminal },
|
|
231
|
+
}),
|
|
232
|
+
exitCode: EXIT.OK,
|
|
233
|
+
};
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const isMalformed = err instanceof SyntaxError;
|
|
236
|
+
return { result: fail('recover', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = { actEvidence, actExplain, actRecover, veylineRoot, loadVeyline };
|
package/docs/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
action and entry-point layer for x402 infrastructure: you state an intent in
|
|
5
5
|
plain language (or call an HTTP endpoint), and callx402 dispatches into the
|
|
6
6
|
existing x402 subsystems that do the real work. It dispatches only. It never
|
|
7
|
-
moves money by itself. The flagship product
|
|
8
|
-
|
|
7
|
+
moves money by itself. The flagship product is Veyline; callx402 is how you
|
|
8
|
+
access it. **Positioning: callx402 is the action layer of Veyline.**
|
|
9
9
|
|
|
10
10
|
Status of this docs set: landing index is live. Pages and assets marked
|
|
11
11
|
"planned" below are not published yet. Nothing here is aspirational: every
|
|
@@ -46,18 +46,20 @@ Check subsystem reachability and feature flags:
|
|
|
46
46
|
callx402 status
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Run an intent in plain language:
|
|
49
|
+
Run an intent in plain language (single quotes: in double quotes your shell
|
|
50
|
+
would expand `$1` to nothing):
|
|
50
51
|
|
|
51
52
|
```sh
|
|
52
|
-
callx402
|
|
53
|
+
callx402 'complete this job for under $1'
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
Diagnose, rescue, route, resolve
|
|
56
|
+
Diagnose, rescue, route, resolve (need the v2.0.0 tree plus each
|
|
57
|
+
subsystem's flag; see the main README, "Full subsystem commands"):
|
|
56
58
|
|
|
57
59
|
```sh
|
|
58
|
-
callx402 diagnose --target
|
|
60
|
+
callx402 diagnose --target 'https://api.example.com/x402/pay'
|
|
59
61
|
callx402 rescue --incident inc_123
|
|
60
|
-
callx402 route --goal
|
|
62
|
+
callx402 route --goal 'fetch 100 product prices for under 0.50 USD'
|
|
61
63
|
callx402 resolve --evidence '{"txHash":"0xabc..."}'
|
|
62
64
|
```
|
|
63
65
|
|
|
@@ -123,7 +125,7 @@ Planned. No recipes published yet.
|
|
|
123
125
|
The subsystem tree callx402 dispatches into: Sentinel, Rescue, Doctor,
|
|
124
126
|
Router, Valuator, SpendGuard, Settlement Resolver, Intent Engine, Capability
|
|
125
127
|
Graph, Economic Planner, MCP fabric, and RevRule. Every dispatch goes
|
|
126
|
-
through the real subsystem in the
|
|
128
|
+
through the real subsystem in the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit),
|
|
127
129
|
which callx402 imports but never modifies. If a subsystem is disabled or
|
|
128
130
|
unreachable, callx402 reports that plainly and fails closed. It never fakes
|
|
129
131
|
success.
|
|
@@ -288,7 +290,7 @@ guards real money movement is ever force-enabled by callx402.
|
|
|
288
290
|
|
|
289
291
|
### Subsystem docs
|
|
290
292
|
|
|
291
|
-
callx402 dispatches into the
|
|
293
|
+
callx402 dispatches into the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit). Its docs
|
|
292
294
|
are the reference for subsystem behavior:
|
|
293
295
|
|
|
294
296
|
- `../x402-paid-api-starter-kit/v2.0.0/README.md` — kit overview
|
package/docs/action-reference.md
CHANGED
|
@@ -67,7 +67,7 @@ Shared notes for all actions:
|
|
|
67
67
|
- **CAN IT MOVE MONEY?** No.
|
|
68
68
|
- **CAN IT CREATE OR RETRY AN AUTHORIZATION?** No.
|
|
69
69
|
- **FAIL-CLOSED CONDITIONS:** Missing `--goal` → exit 2; router disabled or unreachable → exit 3. With no candidates at all, the action is honest ("No candidate tools available... No route selected; nothing executed.") and still exits 0.
|
|
70
|
-
- **EXAMPLE:** `callx402 route --goal
|
|
70
|
+
- **EXAMPLE:** `callx402 route --goal 'fetch 100 product prices for under 0.50 USD' --speed cheap --json`
|
|
71
71
|
- **CURRENT LIMITATIONS:** Without registered tools in the capability graph (experimental; requires `PAYLOAD_MCP_FABRIC=1`) there is nothing to rank, so routing returns an honest no-route result. Route selects; it never executes.
|
|
72
72
|
- **VERIFICATION STATUS:** Tested. `test/cli-dispatch.test.js`: "route: disabled subsystem -> exit 3", "route: enabled, no candidates -> honest no-route, exit 0", "route: missing --goal -> usage error, exit 2", "router dispatch honors the fast speed policy", "router dispatch runs real selectTool/rankTools over candidates".
|
|
73
73
|
|
|
@@ -97,7 +97,7 @@ Shared notes for all actions:
|
|
|
97
97
|
- **CAN IT MOVE MONEY?** It is the only action designed with execution side effects, and it is budget-gated, dry-run capable, and idempotent. In the current build it cannot move money: no live executable route is wired, so execution honestly does not happen. [implementation, tested]
|
|
98
98
|
- **CAN IT CREATE OR RETRY AN AUTHORIZATION?** No in the current build. `--force-retry` after a stored run whose settlement is UNKNOWN is explicitly refused (`unsafe_retry_refused`, exit 5) to prevent double-spend.
|
|
99
99
|
- **FAIL-CLOSED CONDITIONS:** Missing `--intent` → exit 2; over budget → exit 4, zero side effects; above approval threshold without `--approve` → exit 4; retry after UNKNOWN → exit 5; individual stages (intent engine, planner, router, kernel) disabled or unreachable are recorded in `data.stages` and planning continues honestly.
|
|
100
|
-
- **EXAMPLE:** `callx402 execute --intent
|
|
100
|
+
- **EXAMPLE:** `callx402 execute --intent 'complete this job for under $1' --max-budget 1.00 --dry-run --idempotency-key job-42 --json`
|
|
101
101
|
- **CURRENT LIMITATIONS:** No live executable route is wired in this build; `execute` currently plans honestly and stops. Effect proofs, settlement resolution, and receipts are only populated on real execution. Live execution would require a wired route plus an enabled execution kernel.
|
|
102
102
|
- **VERIFICATION STATUS:** Tested. `test/intent-pipeline.test.js` (8 tests, incl. "execute: dry-run intent -> exit 0, honest no-route disposition", "cost above approval threshold without --approve fails closed, exit 4", "explicit --approve passes the threshold gate", "over-budget intent -> budget_refused, exit 4, zero side effects", "duplicate idempotencyKey: second call deduped with ORIGINAL result", "unsafe retry refusal", "intent pipeline walks stages in order", "networks/assets/providers constrain planner, router and spendguard"). `test/safety.test.js` (7 tests) covers the money-safety invariants end to end. Real CLI run verified 2026-10-06: intent-mode dispatch returns the honest "no executable route available" disposition with per-stage detail.
|
|
103
103
|
|
|
@@ -37,3 +37,39 @@ Options:
|
|
|
37
37
|
|
|
38
38
|
Exit codes: 0 ok · 1 failure · 2 usage · 3 disabled/unreachable ·
|
|
39
39
|
4 budget refused · 5 settlement unknown (never auto-retry, never repay).
|
|
40
|
+
|
|
41
|
+
================================================================================
|
|
42
|
+
ADDENDUM 2026-10-07 — per-command help and rail-only routing hint
|
|
43
|
+
================================================================================
|
|
44
|
+
Change: `callx402 <command> --help` now prints that command's own usage
|
|
45
|
+
instead of the global help (bin/callx402.js COMMAND_HELP). Bare
|
|
46
|
+
`callx402 --help` output is unchanged (verified identical above). Unknown
|
|
47
|
+
commands matching a rail-only paid action (safe_retry, duplicate_payment_risk,
|
|
48
|
+
failure_classification, settlement_interpretation, hyphenated aliases) now get
|
|
49
|
+
a routing hint to docs/problem-map.md instead of a bare usage error.
|
|
50
|
+
|
|
51
|
+
$ callx402 recover --help (exit 0)
|
|
52
|
+
callx402 recover — report the safe recovery decision (read-only).
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
callx402 recover <operationId> [--identity <id> | --evidence <json>]
|
|
56
|
+
|
|
57
|
+
Evaluates recorded history against the operation identity and returns the
|
|
58
|
+
recovery decision: RECOVERABLE, SAFE_RETRY, EXECUTED_BUT_UNRECOVERABLE, or
|
|
59
|
+
SETTLEMENT_UNKNOWN, mapped to KNOWN_SAFE or HUMAN_REVIEW. Never charges,
|
|
60
|
+
never executes, never repays. Requires PAYLOAD_VEYLINE_RECOVERY=1.
|
|
61
|
+
|
|
62
|
+
This is the read-only answer to "is a retry safe?" — a SAFE_RETRY verdict
|
|
63
|
+
here is advisory; performing the safe action stays with your own adapters.
|
|
64
|
+
For the paid one-off verdict on a specific retry plan, use the 'safe_retry'
|
|
65
|
+
rail action (quote first, then pay; see docs/problem-map.md). The MCP tool
|
|
66
|
+
x402_recover covers the same free diagnostic.
|
|
67
|
+
|
|
68
|
+
Exit codes: 0 ok · 1 failure · 2 usage (missing identity/evidence) ·
|
|
69
|
+
3 disabled/unreachable · 4 budget refused ·
|
|
70
|
+
5 settlement unknown (never auto-retry, never repay).
|
|
71
|
+
|
|
72
|
+
$ callx402 safe_retry (exit 2, stderr)
|
|
73
|
+
callx402: unknown command 'safe_retry' — 'safe_retry' is a paid on-demand rail action, not a CLI command. Get the free quote, then pay deliberately: see docs/problem-map.md
|
|
74
|
+
Valid commands: diagnose, rescue, route, resolve, doctor, execute, monitor, status, preflight, inspect, config, evidence, explain, recover
|
|
75
|
+
Run 'callx402 --help' for usage, or 'callx402 <command> --help' for one command.
|