callx402 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +227 -2
- package/SPEC.md +264 -0
- package/bin/callx402.js +257 -0
- package/core/config.js +104 -0
- package/core/envelope.js +80 -0
- package/core/index.js +364 -0
- package/core/pipeline.js +371 -0
- package/core/remote.js +242 -0
- package/core/subsystems.js +136 -0
- package/dist/callx402-1.0.0.tgz +0 -0
- package/docs/ACTION_LANGUAGE.md +23 -0
- package/docs/MCP_IDENTITY.md +27 -0
- package/docs/README.md +315 -0
- package/docs/action-reference.md +247 -0
- package/docs/cli-help-verified-2026-10-06.txt +39 -0
- package/docs/logo.png +0 -0
- package/docs/problem-map.md +65 -0
- package/docs/troubleshooting/cx402-402afterfail-001-402-after-failed-settle.md +143 -0
- package/docs/troubleshooting/cx402-agentcap-001-agent-spending.md +114 -0
- package/docs/troubleshooting/cx402-eip3009-001-authorization-checklist.md +124 -0
- package/docs/troubleshooting/cx402-paidnores-001-paid-no-result.md +97 -0
- package/docs/troubleshooting/cx402-safretry-001-retry-after-payment.md +106 -0
- package/docs/troubleshooting/cx402-settlepend-001-settlement-pending.md +92 -0
- package/docs/troubleshooting/cx402-solanafail-001-solana-settlement.md +119 -0
- package/docs/troubleshooting/cx402-verset-001-verify-ok-settle-fails.md +101 -0
- package/docs/visuals/01-action-map.png +0 -0
- package/docs/visuals/02-diagnose.png +0 -0
- package/docs/visuals/03-resolve.png +0 -0
- package/docs/visuals/04-route.png +0 -0
- package/docs/visuals/05-preflight.png +0 -0
- package/docs/visuals/06-inspect.png +0 -0
- package/docs/visuals/07-status.png +0 -0
- package/docs/visuals/08-architecture.svg +49 -0
- package/docs/visuals/MANIFEST.md +40 -0
- package/docs/visuals/raw/01-help.out +39 -0
- package/docs/visuals/raw/02-diagnose.out +2 -0
- package/docs/visuals/raw/03-resolve.out +4 -0
- package/docs/visuals/raw/04-route.out +2 -0
- package/docs/visuals/raw/05-preflight.out +2 -0
- package/docs/visuals/raw/06-inspect.out +23 -0
- package/docs/visuals/raw/07-status.out +16 -0
- package/docs/visuals/render_terminal.py +149 -0
- package/docs/what-is-callx402.md +251 -0
- package/integrations/README.md +62 -0
- package/integrations/core.py +329 -0
- package/integrations/crewai/callx402_crewai.py +109 -0
- package/integrations/facilitator/settle-guard.js +99 -0
- package/integrations/langchain/callx402_tools.py +180 -0
- package/integrations/mcp-clients/README.md +121 -0
- package/integrations/n8n/callx402-incident-guard.json +1 -0
- package/mcp/README.md +63 -0
- package/mcp/index.js +239 -0
- package/package.json +39 -4
- package/scripts/drift-check.js +202 -0
- package/sdk/js/index.js +207 -0
- package/sdk/python/README.md +67 -0
- package/sdk/python/callx402/__init__.py +317 -0
- package/sdk/python/callx402/cli.py +193 -0
- package/sdk/python/pyproject.toml +30 -0
- package/sdk/python/setup.py +56 -0
- package/server/index.js +520 -0
- package/server/openapi.yaml +537 -0
- package/server.json +1 -0
- package/test/cli-dispatch.test.js +248 -0
- package/test/helpers.js +103 -0
- package/test/integrity.test.js +43 -0
- package/test/intent-pipeline.test.js +205 -0
- package/test/remote.test.js +166 -0
- package/test/safety.test.js +234 -0
- package/test/sdk.test.js +198 -0
- package/test/server.test.js +190 -0
- package/test/subsystems.test.js +118 -0
package/bin/callx402.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bin/callx402.js — CLI for the callx402 universal action layer (stdlib only).
|
|
6
|
+
*
|
|
7
|
+
* callx402 <command> [args] [options]
|
|
8
|
+
* callx402 "natural language intent" # intent mode -> execute
|
|
9
|
+
* callx402 --help | --version
|
|
10
|
+
*
|
|
11
|
+
* Global: --json, --timeout <ms>. Exit codes: 0 ok, 1 failure, 2 usage,
|
|
12
|
+
* 3 subsystem disabled/unreachable, 4 budget refused, 5 settlement unknown.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
19
|
+
const core = require(path.join(ROOT, 'core'));
|
|
20
|
+
const { loadConfig, getConfig, setConfig, listConfig } = require(path.join(ROOT, 'core', 'config'));
|
|
21
|
+
const { EXIT } = require(path.join(ROOT, 'core', 'envelope'));
|
|
22
|
+
|
|
23
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
24
|
+
const VERSION = PKG.version || '1.0.0';
|
|
25
|
+
|
|
26
|
+
const COMMANDS = ['diagnose', 'rescue', 'route', 'resolve', 'doctor', 'execute', 'monitor', 'status', 'preflight', 'inspect', 'config'];
|
|
27
|
+
|
|
28
|
+
const HELP = `callx402 ${VERSION} — universal action layer for x402 infrastructure.
|
|
29
|
+
callx402 is the ACTION, not the product name; every command dispatches into
|
|
30
|
+
the existing v2.0.0 subsystems. Nothing here executes payments by itself.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
callx402 <command> [args] [options]
|
|
34
|
+
callx402 "natural language intent" intent mode (plans, never executes blindly)
|
|
35
|
+
callx402 --help | --version
|
|
36
|
+
|
|
37
|
+
Commands:
|
|
38
|
+
diagnose run the x402 doctor over supplied evidence [--target ...]
|
|
39
|
+
rescue incident triage (auth-gated; read-only without auth) --incident <id>
|
|
40
|
+
route select a tool/route for a goal --goal <text>
|
|
41
|
+
resolve resolve settlement state from evidence --evidence <json|@file>
|
|
42
|
+
doctor alias of diagnose
|
|
43
|
+
execute run the intent pipeline --intent <text> [--max-budget N] [--dry-run] [--idempotency-key K]
|
|
44
|
+
monitor sentinel snapshot [--once] or stream [--watch]
|
|
45
|
+
status per-subsystem reachable/enabled/version report
|
|
46
|
+
preflight run MCP preflight checks
|
|
47
|
+
inspect capability-graph lookup/stats [--query <text>]
|
|
48
|
+
config list | get <key> | set <key> <value>
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--json print the full result envelope as JSON
|
|
52
|
+
--timeout <ms> fail the action if it exceeds <ms>
|
|
53
|
+
--approve authorize costs above the approval threshold
|
|
54
|
+
--force-retry explicit retry flag (REFUSED when prior settlement is UNKNOWN)
|
|
55
|
+
--dry-run plan only; no execution side effects
|
|
56
|
+
--speed fast|balanced|cheap routing policy hint
|
|
57
|
+
--max-budget <usd> cap for execute
|
|
58
|
+
--idempotency-key <k> dedupe key for execute
|
|
59
|
+
-h, --help this help
|
|
60
|
+
-v, --version version
|
|
61
|
+
|
|
62
|
+
Exit codes: 0 ok · 1 failure · 2 usage · 3 disabled/unreachable ·
|
|
63
|
+
4 budget refused · 5 settlement unknown (never auto-retry, never repay).
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
function parseArgv(argv) {
|
|
67
|
+
const out = { flags: {}, positionals: [], errors: [] };
|
|
68
|
+
const SHORT = { h: 'help', v: 'version' };
|
|
69
|
+
// Unknown --flags are usage errors, never silently ignored: a typo like
|
|
70
|
+
// --dry-runn must not silently change what the command does.
|
|
71
|
+
const KNOWN_FLAGS = new Set([
|
|
72
|
+
'help', 'version', 'json', 'timeout', 'maxBudget', 'approvalThreshold',
|
|
73
|
+
'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query',
|
|
74
|
+
'target', 'speed', 'interval', 'network', 'networks', 'asset', 'assets',
|
|
75
|
+
'providers', 'deadline', 'risk',
|
|
76
|
+
'dryRun', 'approve', 'forceRetry', 'retry', 'auth', 'watch', 'once',
|
|
77
|
+
]);
|
|
78
|
+
let i = 0;
|
|
79
|
+
while (i < argv.length) {
|
|
80
|
+
const t = argv[i];
|
|
81
|
+
if (t === '--') { out.positionals.push(...argv.slice(i + 1)); break; }
|
|
82
|
+
if (t.startsWith('--')) {
|
|
83
|
+
const eq = t.indexOf('=');
|
|
84
|
+
const rawName = eq === -1 ? t.slice(2) : t.slice(2, eq);
|
|
85
|
+
const name = rawName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
86
|
+
const val = eq === -1 ? null : t.slice(eq + 1);
|
|
87
|
+
if (!KNOWN_FLAGS.has(name)) {
|
|
88
|
+
out.errors.push(`unknown flag --${rawName}`);
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const NEEDS_VALUE = new Set(['timeout', 'maxBudget', 'approvalThreshold', 'idempotencyKey', 'evidence', 'incident', 'goal', 'intent', 'query', 'target', 'speed', 'interval', 'network', 'networks', 'asset', 'assets', 'providers', 'deadline', 'risk']);
|
|
93
|
+
if (NEEDS_VALUE.has(name)) {
|
|
94
|
+
if (val !== null) out.flags[name] = val;
|
|
95
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) out.flags[name] = argv[++i];
|
|
96
|
+
else out.errors.push(`flag --${t.slice(2)} requires a value`);
|
|
97
|
+
} else {
|
|
98
|
+
out.flags[name] = val === null ? true : val;
|
|
99
|
+
}
|
|
100
|
+
} else if (t.startsWith('-') && t.length === 2) {
|
|
101
|
+
const name = SHORT[t[1]];
|
|
102
|
+
if (!name) out.errors.push(`unknown flag ${t}`);
|
|
103
|
+
else out.flags[name] = true;
|
|
104
|
+
} else if (t.startsWith('-')) {
|
|
105
|
+
out.errors.push(`unknown flag ${t}`);
|
|
106
|
+
} else {
|
|
107
|
+
out.positionals.push(t);
|
|
108
|
+
}
|
|
109
|
+
i += 1;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function usageError(message) {
|
|
115
|
+
console.error(`callx402: ${message}\nValid commands: ${COMMANDS.join(', ')}\nRun 'callx402 --help' for usage.`);
|
|
116
|
+
process.exit(EXIT.USAGE);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Concise human-readable summary for non-JSON output. */
|
|
120
|
+
function summarize(envelope) {
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`${envelope.ok ? 'ok' : 'FAILED'} [${envelope.action || '?'}]${envelope.subsystem ? ` via ${envelope.subsystem}` : ''}`);
|
|
123
|
+
if (envelope.disposition) lines.push(envelope.disposition);
|
|
124
|
+
if (envelope.subsystemStatus && envelope.subsystemStatus !== 'ok') lines.push(`subsystemStatus: ${envelope.subsystemStatus}`);
|
|
125
|
+
if (envelope.settlement) lines.push(`settlement: ${envelope.settlement}`);
|
|
126
|
+
if (envelope.txHash) lines.push(`txHash: ${envelope.txHash}`);
|
|
127
|
+
if (envelope.idempotencyKey) lines.push(`idempotencyKey: ${envelope.idempotencyKey}${envelope.deduped ? ' (deduped)' : ''}`);
|
|
128
|
+
if (envelope.error) lines.push(`error [${envelope.error.code}]: ${envelope.error.message}`);
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function statusSummary(envelope) {
|
|
133
|
+
const lines = [envelope.disposition || 'status'];
|
|
134
|
+
const subs = (envelope.data && envelope.data.subsystems) || {};
|
|
135
|
+
for (const [name, s] of Object.entries(subs)) {
|
|
136
|
+
const mark = !s.reachable ? 'UNREACHABLE' : s.enabled ? 'enabled' : `disabled (${s.flag || 'no FLAG export'})`;
|
|
137
|
+
lines.push(` ${name.padEnd(12)} ${mark}${s.version ? ` v${s.version}` : ''}`);
|
|
138
|
+
}
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main() {
|
|
143
|
+
const { flags, positionals, errors } = parseArgv(process.argv.slice(2));
|
|
144
|
+
|
|
145
|
+
if (flags.help) { process.stdout.write(HELP); process.exit(EXIT.OK); }
|
|
146
|
+
if (flags.version) { process.stdout.write(`callx402 ${VERSION}\n`); process.exit(EXIT.OK); }
|
|
147
|
+
if (errors.length > 0) usageError(errors[0]);
|
|
148
|
+
|
|
149
|
+
const [first, ...rest] = positionals;
|
|
150
|
+
|
|
151
|
+
// Bare quoted string -> intent mode: anything not a known command AND looking
|
|
152
|
+
// like natural language (more than one word). A single unknown word is an
|
|
153
|
+
// unknown command (exit 2), per the CLI contract.
|
|
154
|
+
const isCommand = first && COMMANDS.includes(first);
|
|
155
|
+
if (!isCommand && first && first.startsWith('-')) usageError(`unknown input '${first}'`);
|
|
156
|
+
if (!isCommand && positionals.length === 1 && !/\s/.test(first)) usageError(`unknown command '${first}'`);
|
|
157
|
+
const command = isCommand ? first : (first === undefined ? null : '__intent__');
|
|
158
|
+
|
|
159
|
+
// config handled directly (not a subsystem dispatch)
|
|
160
|
+
if (command === 'config') {
|
|
161
|
+
const sub = rest[0];
|
|
162
|
+
try {
|
|
163
|
+
if (sub === 'list' || sub === undefined) {
|
|
164
|
+
const cfg = listConfig();
|
|
165
|
+
if (flags.json) console.log(JSON.stringify(cfg, null, 2));
|
|
166
|
+
else for (const [k, v] of Object.entries(cfg)) console.log(`${k}=${v}`);
|
|
167
|
+
process.exit(EXIT.OK);
|
|
168
|
+
}
|
|
169
|
+
if (sub === 'get') {
|
|
170
|
+
if (!rest[1]) usageError('config get <key>');
|
|
171
|
+
const v = getConfig(rest[1]);
|
|
172
|
+
if (flags.json) console.log(JSON.stringify({ [rest[1]]: v }));
|
|
173
|
+
else console.log(`${rest[1]}=${v}`);
|
|
174
|
+
process.exit(EXIT.OK);
|
|
175
|
+
}
|
|
176
|
+
if (sub === 'set') {
|
|
177
|
+
if (!rest[1] || rest[2] === undefined) usageError('config set <key> <value>');
|
|
178
|
+
const cfg = setConfig(rest[1], rest[2]);
|
|
179
|
+
if (flags.json) console.log(JSON.stringify({ [rest[1]]: cfg[rest[1]] }));
|
|
180
|
+
else console.log(`${rest[1]}=${cfg[rest[1]]}`);
|
|
181
|
+
process.exit(EXIT.OK);
|
|
182
|
+
}
|
|
183
|
+
usageError(`unknown config subcommand '${sub}' (use list|get|set)`);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(`callx402: ${err.message}`);
|
|
186
|
+
process.exit(EXIT.USAGE);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!command) usageError('no command given');
|
|
191
|
+
|
|
192
|
+
const toNum = (v) => (v === undefined ? undefined : Number(v));
|
|
193
|
+
// Comma-separated list flags (also used by the Python SDK: --networks a,b).
|
|
194
|
+
const toList = (v) => (v === undefined ? undefined : String(v).split(',').map((s) => s.trim()).filter(Boolean));
|
|
195
|
+
const args = {
|
|
196
|
+
json: !!flags.json,
|
|
197
|
+
timeoutMs: toNum(flags.timeout),
|
|
198
|
+
evidence: flags.evidence,
|
|
199
|
+
target: flags.target,
|
|
200
|
+
incident: flags.incident,
|
|
201
|
+
goal: flags.goal,
|
|
202
|
+
intent: flags.intent,
|
|
203
|
+
query: flags.query,
|
|
204
|
+
speed: flags.speed,
|
|
205
|
+
maxBudget: toNum(flags.maxBudget),
|
|
206
|
+
approvalThreshold: toNum(flags.approvalThreshold),
|
|
207
|
+
idempotencyKey: flags.idempotencyKey,
|
|
208
|
+
dryRun: flags.dryRun === true || flags.dryRun === 'true',
|
|
209
|
+
approve: flags.approve === true || flags.approve === 'true',
|
|
210
|
+
forceRetry: flags.forceRetry === true || flags.forceRetry === 'true',
|
|
211
|
+
retry: flags.retry === true || flags.retry === 'true',
|
|
212
|
+
auth: flags.auth === true || flags.auth === 'true' || flags.auth === '1' ? true : undefined,
|
|
213
|
+
watch: !!flags.watch,
|
|
214
|
+
once: !!flags.once,
|
|
215
|
+
interval: toNum(flags.interval),
|
|
216
|
+
network: flags.network,
|
|
217
|
+
deadline: flags.deadline,
|
|
218
|
+
risk: flags.risk,
|
|
219
|
+
networks: toList(flags.networks) || (flags.network ? [flags.network] : undefined),
|
|
220
|
+
assets: toList(flags.assets) || (flags.asset ? [flags.asset] : undefined),
|
|
221
|
+
providers: toList(flags.providers),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let actionName = command;
|
|
225
|
+
if (command === '__intent__') {
|
|
226
|
+
actionName = 'execute';
|
|
227
|
+
args.intent = positionals.join(' ');
|
|
228
|
+
} else {
|
|
229
|
+
// positional fallbacks
|
|
230
|
+
if ((command === 'diagnose' || command === 'doctor') && !args.target && rest.length > 0) args.target = rest.join(' ');
|
|
231
|
+
if (command === 'route' && !args.goal && rest.length > 0) args.goal = rest.join(' ');
|
|
232
|
+
if (command === 'execute' && !args.intent && rest.length > 0) args.intent = rest.join(' ');
|
|
233
|
+
if (command === 'inspect' && !args.query && rest.length > 0) args.query = rest.join(' ');
|
|
234
|
+
if (command === 'rescue' && !args.incident && rest.length > 0) args.incident = rest[0];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (Number.isNaN(args.timeoutMs)) usageError('--timeout must be a number (ms)');
|
|
238
|
+
if (args.maxBudget !== undefined && Number.isNaN(args.maxBudget)) usageError('--max-budget must be a number');
|
|
239
|
+
if (args.approvalThreshold !== undefined && Number.isNaN(args.approvalThreshold)) usageError('--approval-threshold must be a number');
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const { result, exitCode } = await core.runAction(actionName, args, { version: VERSION });
|
|
243
|
+
if (args.json) {
|
|
244
|
+
console.log(JSON.stringify(result, null, 2));
|
|
245
|
+
} else if (actionName === 'status') {
|
|
246
|
+
console.log(statusSummary(result));
|
|
247
|
+
} else {
|
|
248
|
+
console.log(summarize(result));
|
|
249
|
+
}
|
|
250
|
+
process.exit(exitCode);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error(`callx402: ${err.message}`);
|
|
253
|
+
process.exit(EXIT.FAIL);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
main();
|
package/core/config.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/config.js — config file (~/.config/callx402/config.json, overridable by
|
|
5
|
+
* $CALLX402_CONFIG) + CALLX402_* env overrides + safe defaults.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
mode: 'local', // local | remote
|
|
14
|
+
remoteUrl: null,
|
|
15
|
+
authToken: null,
|
|
16
|
+
v2Root: null, // null => default relative to v2.0.0 tree
|
|
17
|
+
defaultNetwork: 'base',
|
|
18
|
+
defaultAsset: 'USDC',
|
|
19
|
+
defaultBudgetUsd: 1.00,
|
|
20
|
+
approvalThresholdUsd: 5.00, // above this: require explicit approval, fail closed
|
|
21
|
+
rescueAuth: false, // rescue execute requires explicit auth enable
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// env name -> [config key, coerce]
|
|
25
|
+
const ENV_MAP = {
|
|
26
|
+
CALLX402_V2_ROOT: ['v2Root', String],
|
|
27
|
+
CALLX402_MODE: ['mode', String],
|
|
28
|
+
CALLX402_REMOTE_URL: ['remoteUrl', String],
|
|
29
|
+
CALLX402_AUTH_TOKEN: ['authToken', String],
|
|
30
|
+
CALLX402_DEFAULT_NETWORK: ['defaultNetwork', String],
|
|
31
|
+
CALLX402_DEFAULT_ASSET: ['defaultAsset', String],
|
|
32
|
+
CALLX402_DEFAULT_BUDGET_USD: ['defaultBudgetUsd', Number],
|
|
33
|
+
CALLX402_APPROVAL_THRESHOLD_USD: ['approvalThresholdUsd', Number],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const NUMERIC_KEYS = new Set(['defaultBudgetUsd', 'approvalThresholdUsd']);
|
|
37
|
+
|
|
38
|
+
function configPath() {
|
|
39
|
+
if (process.env.CALLX402_CONFIG) return process.env.CALLX402_CONFIG;
|
|
40
|
+
return path.join(os.homedir(), '.config', 'callx402', 'config.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readFileConfig() {
|
|
44
|
+
const p = configPath();
|
|
45
|
+
try {
|
|
46
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err && err.code !== 'ENOENT') {
|
|
51
|
+
throw new Error(`callx402: config file ${p} is not valid JSON (${err.message})`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function applyEnvOverrides(cfg) {
|
|
58
|
+
const out = { ...cfg };
|
|
59
|
+
for (const [envName, [key, coerce]] of Object.entries(ENV_MAP)) {
|
|
60
|
+
const v = process.env[envName];
|
|
61
|
+
if (v === undefined || v === null || v === '') continue;
|
|
62
|
+
if (coerce === Number) {
|
|
63
|
+
const n = Number(v);
|
|
64
|
+
if (!Number.isFinite(n)) continue;
|
|
65
|
+
out[key] = n;
|
|
66
|
+
} else {
|
|
67
|
+
out[key] = String(v);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Merged effective config: defaults < file < env. */
|
|
74
|
+
function loadConfig() {
|
|
75
|
+
return applyEnvOverrides({ ...DEFAULTS, ...readFileConfig() });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getConfig(key) {
|
|
79
|
+
const cfg = loadConfig();
|
|
80
|
+
if (!(key in DEFAULTS)) throw new Error(`callx402: unknown config key '${key}'`);
|
|
81
|
+
return cfg[key];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listConfig() {
|
|
85
|
+
return loadConfig();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Persist one key to the config file (env overrides are never persisted). */
|
|
89
|
+
function setConfig(key, value) {
|
|
90
|
+
if (!(key in DEFAULTS)) throw new Error(`callx402: unknown config key '${key}' (valid: ${Object.keys(DEFAULTS).join(', ')})`);
|
|
91
|
+
let v = value;
|
|
92
|
+
if (NUMERIC_KEYS.has(key)) {
|
|
93
|
+
v = Number(value);
|
|
94
|
+
if (!Number.isFinite(v)) throw new Error(`callx402: config key '${key}' requires a number`);
|
|
95
|
+
}
|
|
96
|
+
const p = configPath();
|
|
97
|
+
const current = readFileConfig();
|
|
98
|
+
current[key] = v;
|
|
99
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
100
|
+
fs.writeFileSync(p, JSON.stringify(current, null, 2) + '\n', 'utf8');
|
|
101
|
+
return loadConfig();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { DEFAULTS, configPath, loadConfig, getConfig, setConfig, listConfig };
|
package/core/envelope.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/envelope.js — result envelope builders + CLI exit codes.
|
|
5
|
+
*
|
|
6
|
+
* Every dispatch (CLI, SDKs, HTTP) returns the envelope shape from SPEC section 4.
|
|
7
|
+
* `error` is null on success; ok:false whenever error is set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const EXIT = {
|
|
11
|
+
OK: 0, // ok
|
|
12
|
+
FAIL: 1, // generic failure
|
|
13
|
+
USAGE: 2, // usage/validation error
|
|
14
|
+
UNAVAILABLE: 3, // subsystem disabled or unreachable
|
|
15
|
+
BUDGET_REFUSED: 4, // SpendGuard budget refusal
|
|
16
|
+
SETTLEMENT_UNKNOWN: 5, // settlement UNKNOWN: never auto-retry, never repay
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const VALID_STATUSES = new Set(['ok', 'disabled', 'unreachable', 'prototype']);
|
|
20
|
+
|
|
21
|
+
function envelope(partial = {}) {
|
|
22
|
+
const env = {
|
|
23
|
+
ok: partial.ok === true,
|
|
24
|
+
action: partial.action ?? null,
|
|
25
|
+
disposition: partial.disposition ?? null,
|
|
26
|
+
subsystem: partial.subsystem ?? null,
|
|
27
|
+
subsystemStatus: partial.subsystemStatus ?? null,
|
|
28
|
+
data: partial.data ?? {},
|
|
29
|
+
settlement: partial.settlement ?? null,
|
|
30
|
+
txHash: partial.txHash ?? null,
|
|
31
|
+
receipt: partial.receipt ?? {},
|
|
32
|
+
idempotencyKey: partial.idempotencyKey ?? null,
|
|
33
|
+
deduped: partial.deduped === true,
|
|
34
|
+
error: partial.error ?? null,
|
|
35
|
+
};
|
|
36
|
+
if (env.subsystemStatus && !VALID_STATUSES.has(env.subsystemStatus)) {
|
|
37
|
+
env.subsystemStatus = null;
|
|
38
|
+
}
|
|
39
|
+
// Invariant: ok:false whenever error is set; ok:true implies error null.
|
|
40
|
+
if (env.error) env.ok = false;
|
|
41
|
+
if (env.ok) env.error = null;
|
|
42
|
+
return env;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ok(action, { subsystem = null, subsystemStatus = 'ok', disposition = null, data = {}, settlement = null, txHash = null, receipt = {}, idempotencyKey = null, deduped = false } = {}) {
|
|
46
|
+
return envelope({ ok: true, action, subsystem, subsystemStatus, disposition, data, settlement, txHash, receipt, idempotencyKey, deduped, error: null });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(action, { subsystem = null, subsystemStatus = null, code = 'failed', message = 'failed', disposition = null, data = {}, settlement = null, idempotencyKey = null, deduped = false } = {}) {
|
|
50
|
+
return envelope({ ok: false, action, subsystem, subsystemStatus, disposition, data, settlement, idempotencyKey, deduped, error: { code, message } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Map an envelope to a CLI exit code (budget/settlement refusals are explicit). */
|
|
54
|
+
function exitCodeFor(result) {
|
|
55
|
+
if (!result) return EXIT.FAIL;
|
|
56
|
+
if (result.ok) return EXIT.OK;
|
|
57
|
+
const code = result.error && result.error.code;
|
|
58
|
+
switch (code) {
|
|
59
|
+
case 'usage':
|
|
60
|
+
case 'validation':
|
|
61
|
+
case 'unknown_action':
|
|
62
|
+
return EXIT.USAGE;
|
|
63
|
+
case 'subsystem_disabled':
|
|
64
|
+
case 'subsystem_unreachable':
|
|
65
|
+
case 'auth_required':
|
|
66
|
+
case 'no_executable_route':
|
|
67
|
+
case 'remote_unreachable':
|
|
68
|
+
return EXIT.UNAVAILABLE;
|
|
69
|
+
case 'budget_refused':
|
|
70
|
+
case 'approval_required':
|
|
71
|
+
return EXIT.BUDGET_REFUSED;
|
|
72
|
+
case 'settlement_unknown':
|
|
73
|
+
case 'unsafe_retry_refused':
|
|
74
|
+
return EXIT.SETTLEMENT_UNKNOWN;
|
|
75
|
+
default:
|
|
76
|
+
return EXIT.FAIL;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { EXIT, VALID_STATUSES, envelope, ok, fail, exitCodeFor };
|