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
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
const { test } = require('node:test');
|
|
17
17
|
const path = require('node:path');
|
|
18
18
|
const { runIntent, runAction } = require('../core/index.js');
|
|
19
|
-
const { scratchDir, withEnv } = require('./helpers');
|
|
20
|
-
|
|
19
|
+
const { scratchDir, withEnv, needsV2Tree } = require('./helpers');
|
|
21
20
|
const scratch = scratchDir('pipeline');
|
|
22
21
|
const hermetic = {
|
|
23
22
|
CALLX402_CONFIG: path.join(scratch, 'config.json'),
|
|
@@ -169,7 +168,7 @@ test('networks/assets/providers constrain planner, router and spendguard', async
|
|
|
169
168
|
});
|
|
170
169
|
});
|
|
171
170
|
|
|
172
|
-
test('real end-to-end run (no doubles, all flags off): honest no-route', async (t) => {
|
|
171
|
+
test('real end-to-end run (no doubles, all flags off): honest no-route', needsV2Tree, async (t) => {
|
|
173
172
|
await withEnv(hermetic, async () => {
|
|
174
173
|
const { result, exitCode } = await runIntent('summarize this for under 1 dollar', { maxBudget: 1 });
|
|
175
174
|
t.assert.strictEqual(exitCode, 0);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { buildAuthMessage, actionRequestHash, readSignedAuthorization } = require('../core/payer-auth.js');
|
|
5
|
+
const { invokeRail, isRailCommand } = require('../core/rail.js');
|
|
6
|
+
|
|
7
|
+
const txHash = '0x' + '22'.repeat(32);
|
|
8
|
+
const wallet = '0x' + '11'.repeat(20);
|
|
9
|
+
const recipient = '0x' + '33'.repeat(20);
|
|
10
|
+
const signature = '0x' + '44'.repeat(65);
|
|
11
|
+
const authorization = () => ({
|
|
12
|
+
wallet, action: 'resolve', txHash, quote_id: 'q_test',
|
|
13
|
+
quote_inputs: { action: 'resolve', path: 'x402', issuedAt: 1 }, quoted_price_usd: '0.25',
|
|
14
|
+
request_hash: actionRequestHash('resolve', { evidence: '{}' }),
|
|
15
|
+
network: 'eip155:8453', recipient, nonce: 'nonce_unique_1234',
|
|
16
|
+
expiry: Math.floor(Date.now() / 1000) + 300, signature,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('client and Rail use canonical EIP-191 signer message', () => {
|
|
20
|
+
const expected = [
|
|
21
|
+
'Payload x402 Authorization',
|
|
22
|
+
'wallet:' + wallet,
|
|
23
|
+
'action:resolve',
|
|
24
|
+
'txHash:' + txHash,
|
|
25
|
+
'quote_id:q_test',
|
|
26
|
+
'request_hash:' + authorization().request_hash,
|
|
27
|
+
'network:eip155:8453',
|
|
28
|
+
'recipient:' + recipient,
|
|
29
|
+
'nonce:nonce_unique_1234',
|
|
30
|
+
'expiry:' + authorization().expiry,
|
|
31
|
+
].join('\n');
|
|
32
|
+
assert.equal(buildAuthMessage(authorization()), expected);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('unsigned, malformed and expired authorizations are rejected before network payment', () => {
|
|
36
|
+
assert.throws(() => readSignedAuthorization('{}'), /wallet/);
|
|
37
|
+
const bad = { ...authorization(), expiry: Math.floor(Date.now()/1000) - 1 };
|
|
38
|
+
assert.throws(() => readSignedAuthorization(JSON.stringify(bad)), /expired/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('hosted CLI does not route local-only actions to paid Rail', () => {
|
|
42
|
+
assert.equal(isRailCommand('execute'), false);
|
|
43
|
+
assert.equal(isRailCommand('rescue'), false);
|
|
44
|
+
assert.equal(isRailCommand('resolve'), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('signed noninteractive redemption sends signature, never wallet secrets', async () => {
|
|
48
|
+
const previous = global.fetch;
|
|
49
|
+
const requests = [];
|
|
50
|
+
global.fetch = async (url, init = {}) => {
|
|
51
|
+
requests.push({ url, init });
|
|
52
|
+
const quote = String(url).includes('/quote?');
|
|
53
|
+
const body = quote ? { quoted_price_usd: '0.25', quote_id: 'q_test', quote_inputs: { action: 'resolve', path: 'x402' } } :
|
|
54
|
+
{ ok: true, invocation_id: 'invo_mock', via: 'x402', execution: { executed: true, result: { state: 'UNKNOWN' } } };
|
|
55
|
+
return { status: 200, text: async () => JSON.stringify(body) };
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const result = await invokeRail('resolve', {
|
|
59
|
+
evidence: '{}', txHash, payerAuth: JSON.stringify(authorization()),
|
|
60
|
+
}, { yes: true, json: true });
|
|
61
|
+
assert.equal(result.ok, true);
|
|
62
|
+
assert.equal(requests.length, 1);
|
|
63
|
+
const body = JSON.parse(requests[0].init.body);
|
|
64
|
+
assert.deepEqual(body.quote, authorization().quote_inputs);
|
|
65
|
+
assert.equal(body.txHash, txHash);
|
|
66
|
+
assert.equal(body.payer_auth.wallet, wallet);
|
|
67
|
+
assert.equal(body.payer_auth.signature, signature);
|
|
68
|
+
assert.equal(body.payer_auth.quote_id, 'q_test');
|
|
69
|
+
assert.equal('privateKey' in body, false);
|
|
70
|
+
} finally {
|
|
71
|
+
global.fetch = previous;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('stale signature for a different quote fails without submitting a payment proof', async () => {
|
|
76
|
+
const previous = global.fetch;
|
|
77
|
+
const requests = [];
|
|
78
|
+
global.fetch = async (url, init = {}) => {
|
|
79
|
+
requests.push({ url, init });
|
|
80
|
+
return { status: 200, text: async () => JSON.stringify({ quoted_price_usd: '0.25', quote_id: 'q_changed', quote_inputs: { action: 'resolve', path: 'x402' } }) };
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const result = await invokeRail('resolve', {
|
|
84
|
+
txHash, payerAuth: JSON.stringify({ ...authorization(), action: 'recover' }),
|
|
85
|
+
}, { yes: true, json: true });
|
|
86
|
+
assert.equal(result.ok, false);
|
|
87
|
+
assert.match(result.error, /current quote/);
|
|
88
|
+
assert.equal(requests.length, 0);
|
|
89
|
+
} finally {
|
|
90
|
+
global.fetch = previous;
|
|
91
|
+
}
|
|
92
|
+
});
|
package/test/remote.test.js
CHANGED
|
@@ -14,8 +14,7 @@ const { test } = require('node:test');
|
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
const { runAction, runIntent } = require('../core/index.js');
|
|
16
16
|
const { createServer } = require('../server/index.js');
|
|
17
|
-
const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv } = require('./helpers');
|
|
18
|
-
|
|
17
|
+
const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv, needsV2Tree } = require('./helpers');
|
|
19
18
|
const scratch = scratchDir('remote');
|
|
20
19
|
|
|
21
20
|
/** Start a real callx402 server on an ephemeral port. */
|
|
@@ -148,7 +147,7 @@ test('remote auth: server with token rejects missing/wrong bearer, accepts the r
|
|
|
148
147
|
}
|
|
149
148
|
});
|
|
150
149
|
|
|
151
|
-
test('remote money-safety: settlement UNKNOWN envelope crosses the wire unchanged (exit 5)', async (t) => {
|
|
150
|
+
test('remote money-safety: settlement UNKNOWN envelope crosses the wire unchanged (exit 5)', needsV2Tree, async (t) => {
|
|
152
151
|
// The server runs in-process, so the subsystem flag belongs to the parent
|
|
153
152
|
// env; the CLI subprocess only carries the remote-mode config.
|
|
154
153
|
await withEnv({ PAYLOAD_SETTLEMENT_RESOLVER: '1' }, async () => {
|
package/test/safety.test.js
CHANGED
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
const { test } = require('node:test');
|
|
16
16
|
const path = require('node:path');
|
|
17
17
|
const { runIntent, runAction } = require('../core/index.js');
|
|
18
|
-
const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv } = require('./helpers');
|
|
19
|
-
|
|
18
|
+
const { scratchDir, cliEnv, runCLI, assertEnvelope, withEnv, needsV2Tree } = require('./helpers');
|
|
20
19
|
const scratch = scratchDir('safety');
|
|
21
20
|
const hermetic = {
|
|
22
21
|
CALLX402_CONFIG: path.join(scratch, 'config.json'),
|
|
@@ -99,7 +98,7 @@ function walkPairs(o, prefix = '') {
|
|
|
99
98
|
return out;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
test('settlement UNKNOWN: exit 5, fail-closed, no retry/repay instruction', async (t) => {
|
|
101
|
+
test('settlement UNKNOWN: exit 5, fail-closed, no retry/repay instruction', needsV2Tree, async (t) => {
|
|
103
102
|
const r = await runCLI(
|
|
104
103
|
['resolve', '--evidence', '{}', '--json'],
|
|
105
104
|
cliEnv(scratch, { PAYLOAD_SETTLEMENT_RESOLVER: '1' })
|
|
@@ -164,7 +163,7 @@ test('unsafe retry refusal: stored UNKNOWN + --force-retry -> refused, exit 5',
|
|
|
164
163
|
});
|
|
165
164
|
});
|
|
166
165
|
|
|
167
|
-
test('unsafe retry refusal via real CLI + file-backed store', async (t) => {
|
|
166
|
+
test('unsafe retry refusal via real CLI + file-backed store', needsV2Tree, async (t) => {
|
|
168
167
|
const idemFile = path.join(scratch, 'idem-unsafe.jsonl');
|
|
169
168
|
const env = cliEnv(scratch, { CALLX402_IDEMPOTENCY_FILE: idemFile });
|
|
170
169
|
const seeded = {
|
|
@@ -222,7 +221,7 @@ test('timeout: slow subsystem + tiny timeoutMs -> timeout error, exit 1, no hang
|
|
|
222
221
|
});
|
|
223
222
|
});
|
|
224
223
|
|
|
225
|
-
test('timeout via CLI --timeout: monitor --watch is cut off -> exit 1', async (t) => {
|
|
224
|
+
test('timeout via CLI --timeout: monitor --watch is cut off -> exit 1', needsV2Tree, async (t) => {
|
|
226
225
|
// --watch streams forever; --timeout 200ms must win the race and exit 1.
|
|
227
226
|
const r = await runCLI(
|
|
228
227
|
['monitor', '--watch', '--interval', '50', '--timeout', '200'],
|
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
|
+
});
|
package/mcp-publisher
DELETED
|
Binary file
|