callx402 1.0.0 → 1.0.2
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/README.md +164 -11
- package/bin/callx402.js +296 -13
- package/core/index.js +35 -6
- package/core/rail.js +233 -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/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/mcp-publisher +0 -0
- package/package.json +9 -18
- package/server.json +23 -1
- 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/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/test/sdk.test.js
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
const { test } = require('node:test');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
const { execFile } = require('node:child_process');
|
|
16
|
-
const { ENVELOPE_KEYS, scratchDir, withEnv } = require('./helpers');
|
|
17
|
-
|
|
16
|
+
const { ENVELOPE_KEYS, scratchDir, withEnv, needsV2Tree } = require('./helpers');
|
|
18
17
|
const scratch = scratchDir('sdk');
|
|
19
18
|
const hermetic = {
|
|
20
19
|
CALLX402_CONFIG: path.join(scratch, 'config.json'),
|
|
@@ -45,7 +44,7 @@ test('JS SDK: callx402({intent}) returns a full envelope (in-process)', async (t
|
|
|
45
44
|
});
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
test('JS SDK: version, status(), and sub-actions', async (t) => {
|
|
47
|
+
test('JS SDK: version, status(), and sub-actions', needsV2Tree, async (t) => {
|
|
49
48
|
await withEnv(hermetic, async () => {
|
|
50
49
|
t.assert.strictEqual(sdk.version, '1.0.0');
|
|
51
50
|
t.assert.strictEqual(require('../sdk/js/index.js').callx402.version, '1.0.0');
|
|
@@ -74,7 +73,7 @@ test('JS SDK: invalid opts throw TypeError (fail fast, no dispatch)', async (t)
|
|
|
74
73
|
});
|
|
75
74
|
});
|
|
76
75
|
|
|
77
|
-
test('JS SDK: action dispatch through runAction', async (t) => {
|
|
76
|
+
test('JS SDK: action dispatch through runAction', needsV2Tree, async (t) => {
|
|
78
77
|
await withEnv(hermetic, async () => {
|
|
79
78
|
const r = await sdk({ action: 'resolve', args: { evidence: '{}' } });
|
|
80
79
|
// Settlement subsystem disabled without its flag: honest failure.
|
package/test/server.test.js
CHANGED
|
@@ -15,8 +15,7 @@ const { test } = require('node:test');
|
|
|
15
15
|
const http = require('node:http');
|
|
16
16
|
const path = require('node:path');
|
|
17
17
|
const { createServer } = require('../server/index.js');
|
|
18
|
-
const { ENVELOPE_KEYS, scratchDir, withEnv } = require('./helpers');
|
|
19
|
-
|
|
18
|
+
const { ENVELOPE_KEYS, scratchDir, withEnv, needsV2Tree } = require('./helpers');
|
|
20
19
|
// The in-process server's core reads the parent env: keep the idempotency
|
|
21
20
|
// store hermetic for the whole file (each test file is its own process).
|
|
22
21
|
const serverScratch = scratchDir('server');
|
|
@@ -59,7 +58,7 @@ function assertEnvelopeShape(t, env, action) {
|
|
|
59
58
|
if (action !== undefined) t.assert.strictEqual(env.action, action);
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
test('GET /health -> 200 with version and subsystem map', async (t) => {
|
|
61
|
+
test('GET /health -> 200 with version and subsystem map', needsV2Tree, async (t) => {
|
|
63
62
|
const { server, port } = await startServer();
|
|
64
63
|
try {
|
|
65
64
|
const r = await request(port, 'GET', '/health');
|
|
@@ -134,7 +133,7 @@ test('unknown route -> 404', async (t) => {
|
|
|
134
133
|
}
|
|
135
134
|
});
|
|
136
135
|
|
|
137
|
-
test('disabled subsystem over HTTP -> 503, honest envelope', async (t) => {
|
|
136
|
+
test('disabled subsystem over HTTP -> 503, honest envelope', needsV2Tree, async (t) => {
|
|
138
137
|
const { server, port } = await startServer();
|
|
139
138
|
try {
|
|
140
139
|
const r = await request(port, 'POST', '/resolve', { body: { evidence: {} } });
|
|
@@ -147,7 +146,7 @@ test('disabled subsystem over HTTP -> 503, honest envelope', async (t) => {
|
|
|
147
146
|
}
|
|
148
147
|
});
|
|
149
148
|
|
|
150
|
-
test('settlement UNKNOWN over HTTP -> 409 with fail-closed envelope', async (t) => {
|
|
149
|
+
test('settlement UNKNOWN over HTTP -> 409 with fail-closed envelope', needsV2Tree, async (t) => {
|
|
151
150
|
await withEnv({ PAYLOAD_SETTLEMENT_RESOLVER: '1' }, async () => {
|
|
152
151
|
const { server, port } = await startServer();
|
|
153
152
|
try {
|
package/test/subsystems.test.js
CHANGED
|
@@ -12,8 +12,7 @@ const { test } = require('node:test');
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { runAction } = require('../core/index.js');
|
|
14
14
|
const { getSubsystem } = require('../core/subsystems.js');
|
|
15
|
-
const { assertEnvelope, scratchDir, withEnv } = require('./helpers');
|
|
16
|
-
|
|
15
|
+
const { assertEnvelope, scratchDir, withEnv, needsV2Tree } = require('./helpers');
|
|
17
16
|
const scratch = scratchDir('subsys');
|
|
18
17
|
const hermetic = {
|
|
19
18
|
CALLX402_CONFIG: path.join(scratch, 'config.json'),
|
|
@@ -26,7 +25,7 @@ function realModule(name) {
|
|
|
26
25
|
return h.module;
|
|
27
26
|
}
|
|
28
27
|
|
|
29
|
-
test('rescue dispatch calls the real rescue module surface; no token -> auth_required exit 3', async (t) => {
|
|
28
|
+
test('rescue dispatch calls the real rescue module surface; no token -> auth_required exit 3', needsV2Tree, async (t) => {
|
|
30
29
|
await withEnv(hermetic, async () => {
|
|
31
30
|
const { result, exitCode } = await runAction('rescue', { incident: 'inc-9' }, { deps: { rescue: realModule('rescue') } });
|
|
32
31
|
t.assert.strictEqual(exitCode, 3);
|
|
@@ -36,7 +35,7 @@ test('rescue dispatch calls the real rescue module surface; no token -> auth_req
|
|
|
36
35
|
});
|
|
37
36
|
});
|
|
38
37
|
|
|
39
|
-
test('rescue dispatch with --auth runs real triage (read-only)', async (t) => {
|
|
38
|
+
test('rescue dispatch with --auth runs real triage (read-only)', needsV2Tree, async (t) => {
|
|
40
39
|
await withEnv(hermetic, async () => {
|
|
41
40
|
const { result, exitCode } = await runAction('rescue', { incident: 'inc-9', auth: true }, { deps: { rescue: realModule('rescue') } });
|
|
42
41
|
t.assert.strictEqual(exitCode, 0);
|
|
@@ -47,7 +46,7 @@ test('rescue dispatch with --auth runs real triage (read-only)', async (t) => {
|
|
|
47
46
|
});
|
|
48
47
|
});
|
|
49
48
|
|
|
50
|
-
test('doctor dispatch executes real runMcpDoctor and returns stages', async (t) => {
|
|
49
|
+
test('doctor dispatch executes real runMcpDoctor and returns stages', needsV2Tree, async (t) => {
|
|
51
50
|
await withEnv(hermetic, async () => {
|
|
52
51
|
const { result, exitCode } = await runAction('diagnose', {}, { deps: { doctor: realModule('doctor') } });
|
|
53
52
|
t.assert.strictEqual(exitCode, 0);
|
|
@@ -62,7 +61,7 @@ test('doctor dispatch executes real runMcpDoctor and returns stages', async (t)
|
|
|
62
61
|
});
|
|
63
62
|
});
|
|
64
63
|
|
|
65
|
-
test('router dispatch runs real selectTool/rankTools over candidates', async (t) => {
|
|
64
|
+
test('router dispatch runs real selectTool/rankTools over candidates', needsV2Tree, async (t) => {
|
|
66
65
|
// The real smart-router asserts PAYLOAD_MCP_FABRIC=1 on rank/select.
|
|
67
66
|
await withEnv({ ...hermetic, PAYLOAD_MCP_FABRIC: '1' }, async () => {
|
|
68
67
|
const candidates = [
|
|
@@ -87,7 +86,7 @@ test('router dispatch runs real selectTool/rankTools over candidates', async (t)
|
|
|
87
86
|
});
|
|
88
87
|
});
|
|
89
88
|
|
|
90
|
-
test('router dispatch honors the fast speed policy', async (t) => {
|
|
89
|
+
test('router dispatch honors the fast speed policy', needsV2Tree, async (t) => {
|
|
91
90
|
await withEnv({ ...hermetic, PAYLOAD_MCP_FABRIC: '1' }, async () => {
|
|
92
91
|
const candidates = [
|
|
93
92
|
{ tool: 'cheap-slow', price_usd: 0.2, expected_latency_ms: 900 },
|
|
@@ -102,7 +101,7 @@ test('router dispatch honors the fast speed policy', async (t) => {
|
|
|
102
101
|
});
|
|
103
102
|
});
|
|
104
103
|
|
|
105
|
-
test('settlement dispatch resolves real states (DEFINITELY_PAID evidence)', async (t) => {
|
|
104
|
+
test('settlement dispatch resolves real states (DEFINITELY_PAID evidence)', needsV2Tree, async (t) => {
|
|
106
105
|
await withEnv(hermetic, async () => {
|
|
107
106
|
const settlement = realModule('settlement');
|
|
108
107
|
const probe = settlement.resolve({ txHash: '0x' + 'ab'.repeat(32), confirmations: 12, expectedAmount: '1.00', actualAmount: '1.00', asset: 'USDC' });
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* test/veyline-commands.test.js — ADDITIVE test for the Veyline Recovery
|
|
5
|
+
* track commands: evidence, explain, recover.
|
|
6
|
+
*
|
|
7
|
+
* Does not touch existing commands. Uses core.runAction directly with a
|
|
8
|
+
* temp --dir so no fixture pollutes the repo. Saves/restores env flags.
|
|
9
|
+
*/
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const { runAction, ACTIONS } = require('../core/index');
|
|
17
|
+
|
|
18
|
+
const FLAGS = ['PAYLOAD_VEYLINE_LEDGER', 'PAYLOAD_VEYLINE_RECOVERY'];
|
|
19
|
+
let saved = {};
|
|
20
|
+
|
|
21
|
+
function envOn() {
|
|
22
|
+
saved = {};
|
|
23
|
+
for (const f of FLAGS) { saved[f] = process.env[f]; process.env[f] = '1'; }
|
|
24
|
+
}
|
|
25
|
+
function envOff() {
|
|
26
|
+
for (const f of FLAGS) {
|
|
27
|
+
if (saved[f] === undefined) delete process.env[f];
|
|
28
|
+
else process.env[f] = saved[f];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tmpDir() {
|
|
33
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'callx402-veyline-'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Local-mode gate: the veyline tree is the optional advanced self-hosted
|
|
37
|
+
// runtime, not a requirement. On a clean machine (no tree) the tree-dependent
|
|
38
|
+
// tests skip — the CLI routes to the hosted rail by default instead.
|
|
39
|
+
const VEYLINE_ROOT = path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
|
|
40
|
+
let treeAvailable = false;
|
|
41
|
+
try { require(path.join(VEYLINE_ROOT, 'operation-ledger.js')); treeAvailable = true; } catch { treeAvailable = false; }
|
|
42
|
+
const needsTree = { skip: !treeAvailable };
|
|
43
|
+
|
|
44
|
+
function seedLedger(dir) {
|
|
45
|
+
// Seed through the real veyline modules so recover() can hit the cache.
|
|
46
|
+
const root = path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
|
|
47
|
+
const L = require(path.join(root, 'operation-ledger.js'));
|
|
48
|
+
const R = require(path.join(root, 'recovery-state.js'));
|
|
49
|
+
const ledger = L.createOperationLedger({ dir });
|
|
50
|
+
ledger.record({ operationId: 'op_cli1', kind: 'begin', protocol: 'x402', actor: 'client', summary: 'begins' });
|
|
51
|
+
ledger.record({ operationId: 'op_cli1', kind: 'payment', protocol: 'x402', actor: 'fac', summary: 'settled', states: { payment: 'SETTLED' } });
|
|
52
|
+
ledger.record({ operationId: 'op_cli1', kind: 'execution', protocol: 'MCP', actor: 'prov', summary: 'done', states: { execution: 'SUCCEEDED' } });
|
|
53
|
+
ledger.record({ operationId: 'op_cli1', kind: 'delivery', protocol: 'MCP', actor: 'prov', summary: 'lost', states: { delivery: 'LOST' } });
|
|
54
|
+
const cache = R.createFileBackedCache({ file: path.join(dir, 'recovery-cache.jsonl') });
|
|
55
|
+
const net = R.createRecoveryNetwork({ ledger, cache });
|
|
56
|
+
const stored = net.storeSettledResult({
|
|
57
|
+
toolName: 't', args: {}, payer: 'payer_1', paymentAuthFingerprint: 'fp1',
|
|
58
|
+
operationId: 'op_cli1', result: { ok: true }, settlementTx: '0x1',
|
|
59
|
+
});
|
|
60
|
+
return stored.identity;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('ACTIONS includes the three new commands and no existing command was removed', () => {
|
|
64
|
+
for (const c of ['diagnose', 'rescue', 'route', 'resolve', 'doctor', 'execute', 'monitor', 'status', 'preflight', 'inspect',
|
|
65
|
+
'evidence', 'explain', 'recover']) {
|
|
66
|
+
assert.ok(ACTIONS.includes(c), `ACTIONS missing ${c}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('evidence: reports recorded events for a known operation', needsTree, async () => {
|
|
71
|
+
envOn();
|
|
72
|
+
const dir = tmpDir();
|
|
73
|
+
try {
|
|
74
|
+
seedLedger(dir);
|
|
75
|
+
const { result, exitCode } = await runAction('evidence', { operationId: 'op_cli1', dir }, {});
|
|
76
|
+
assert.equal(exitCode, 0);
|
|
77
|
+
assert.equal(result.ok, true);
|
|
78
|
+
assert.equal(result.action, 'evidence');
|
|
79
|
+
assert.equal(result.data.operationId, 'op_cli1');
|
|
80
|
+
assert.ok(result.data.eventCount >= 4);
|
|
81
|
+
assert.equal(result.data.states.payment, 'SETTLED');
|
|
82
|
+
} finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('evidence: unknown operation is honest no_basis, still exit 0', needsTree, async () => {
|
|
86
|
+
envOn();
|
|
87
|
+
const dir = tmpDir();
|
|
88
|
+
try {
|
|
89
|
+
const { result, exitCode } = await runAction('evidence', { operationId: 'op_nope', dir }, {});
|
|
90
|
+
assert.equal(exitCode, 0);
|
|
91
|
+
assert.equal(result.ok, true);
|
|
92
|
+
assert.match(result.data.basis, /no_basis/);
|
|
93
|
+
} finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('evidence: disabled flag -> exit 3, nothing executed', needsTree, async () => {
|
|
97
|
+
envOff();
|
|
98
|
+
const { result, exitCode } = await runAction('evidence', { operationId: 'x', dir: tmpDir() }, {});
|
|
99
|
+
assert.equal(exitCode, 3);
|
|
100
|
+
assert.equal(result.ok, false);
|
|
101
|
+
assert.equal(result.error.code, 'subsystem_disabled');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('explain: plain-language assessment of an operation', needsTree, async () => {
|
|
105
|
+
envOn();
|
|
106
|
+
const dir = tmpDir();
|
|
107
|
+
try {
|
|
108
|
+
seedLedger(dir);
|
|
109
|
+
const { result, exitCode } = await runAction('explain', { operationId: 'op_cli1', dir }, {});
|
|
110
|
+
assert.equal(exitCode, 0);
|
|
111
|
+
assert.ok(result.disposition.includes('op_cli1'));
|
|
112
|
+
assert.ok(result.data.assessment.length > 0);
|
|
113
|
+
assert.ok(Array.isArray(result.data.trail));
|
|
114
|
+
} finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('explain: missing operationId -> usage error', needsTree, async () => {
|
|
118
|
+
envOn();
|
|
119
|
+
try {
|
|
120
|
+
const { result, exitCode } = await runAction('explain', {}, {});
|
|
121
|
+
assert.equal(exitCode, 2);
|
|
122
|
+
assert.equal(result.error.code, 'usage');
|
|
123
|
+
} finally { envOff(); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('recover: RECOVERABLE decision returned read-only with the prior result', needsTree, async () => {
|
|
127
|
+
envOn();
|
|
128
|
+
const dir = tmpDir();
|
|
129
|
+
try {
|
|
130
|
+
const identity = seedLedger(dir);
|
|
131
|
+
const { result, exitCode } = await runAction('recover', {
|
|
132
|
+
operationId: 'op_cli1', dir, identity,
|
|
133
|
+
evidence: JSON.stringify({ payer: 'payer_1', paymentAuthFingerprint: 'fp1' }),
|
|
134
|
+
}, {});
|
|
135
|
+
assert.equal(exitCode, 0);
|
|
136
|
+
assert.equal(result.data.decision.recoveryState, 'RECOVERABLE');
|
|
137
|
+
assert.equal(result.data.terminal, 'KNOWN_SAFE');
|
|
138
|
+
assert.deepEqual(result.data.decision.result, { ok: true });
|
|
139
|
+
assert.match(result.disposition, /Read-only/);
|
|
140
|
+
} finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('recover: builds the identity from evidence fields when --identity is absent', needsTree, async () => {
|
|
144
|
+
envOn();
|
|
145
|
+
const dir = tmpDir();
|
|
146
|
+
try {
|
|
147
|
+
seedLedger(dir); // seeds toolName 't', args {}, payer payer_1, fp1, no traceId
|
|
148
|
+
const { result, exitCode } = await runAction('recover', {
|
|
149
|
+
operationId: 'op_cli1', dir,
|
|
150
|
+
evidence: JSON.stringify({ toolName: 't', args: {}, payer: 'payer_1', paymentAuthFingerprint: 'fp1' }),
|
|
151
|
+
}, {});
|
|
152
|
+
assert.equal(exitCode, 0);
|
|
153
|
+
assert.equal(result.data.decision.recoveryState, 'RECOVERABLE');
|
|
154
|
+
} finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('recover: unknown identity with no evidence -> SETTLEMENT_UNKNOWN, never a repay', needsTree, async () => {
|
|
158
|
+
envOn();
|
|
159
|
+
const dir = tmpDir();
|
|
160
|
+
try {
|
|
161
|
+
const { result, exitCode } = await runAction('recover', {
|
|
162
|
+
operationId: 'op_cli1', dir, identity: 'opid_unknown',
|
|
163
|
+
evidence: JSON.stringify({ payer: 'payer_1', paymentAuthFingerprint: 'fp1' }),
|
|
164
|
+
}, {});
|
|
165
|
+
assert.equal(exitCode, 0);
|
|
166
|
+
assert.equal(result.data.decision.recoveryState, 'SETTLEMENT_UNKNOWN');
|
|
167
|
+
assert.equal(result.data.terminal, 'HUMAN_REVIEW');
|
|
168
|
+
assert.ok(result.data.decision.actionsForbidden.includes('blind_repay'));
|
|
169
|
+
} finally { envOff(); fs.rmSync(dir, { recursive: true, force: true }); }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('recover: missing identity and insufficient evidence fields -> usage error', needsTree, async () => {
|
|
173
|
+
envOn();
|
|
174
|
+
try {
|
|
175
|
+
const { result, exitCode } = await runAction('recover', { operationId: 'op_cli1', dir: tmpDir() }, {});
|
|
176
|
+
assert.equal(exitCode, 2);
|
|
177
|
+
assert.equal(result.error.code, 'usage');
|
|
178
|
+
} finally { envOff(); }
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('recover: disabled flag -> exit 3', needsTree, async () => {
|
|
182
|
+
envOff();
|
|
183
|
+
const { result, exitCode } = await runAction('recover', { operationId: 'x', identity: 'y' }, {});
|
|
184
|
+
assert.equal(exitCode, 3);
|
|
185
|
+
assert.equal(result.error.code, 'subsystem_disabled');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('existing commands still dispatch: unknown command lists the new commands too', async () => {
|
|
189
|
+
const { result, exitCode } = await runAction('frobnicate', {}, {});
|
|
190
|
+
assert.equal(exitCode, 2);
|
|
191
|
+
assert.ok(result.data.commands.includes('evidence'));
|
|
192
|
+
assert.ok(result.data.commands.includes('recover'));
|
|
193
|
+
});
|