@trucore/openclaw-atf 0.1.0 → 0.2.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 +226 -12
- package/examples/README.md +134 -0
- package/examples/disable-plugin.json +3 -0
- package/examples/full-config.json +12 -0
- package/examples/minimal-enable.json +5 -0
- package/examples/prefer-api.json +8 -0
- package/examples/prefer-cli.json +7 -0
- package/openclaw.plugin.json +2 -1
- package/package.json +11 -2
- package/src/adoption_advisor.mjs +425 -0
- package/src/backend.mjs +230 -0
- package/src/billing_claim.mjs +630 -0
- package/src/config.mjs +317 -0
- package/src/contracts/deny_codes.mjs +217 -0
- package/src/contracts/index.mjs +52 -0
- package/src/contracts/result_builder.mjs +132 -0
- package/src/contracts/schemas.mjs +402 -0
- package/src/contracts/status_codes.mjs +148 -0
- package/src/doctor.mjs +207 -0
- package/src/index.mjs +1168 -70
- package/src/tool_response.mjs +181 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adoption_advisor.mjs — Deterministic ATF adoption advisor
|
|
3
|
+
*
|
|
4
|
+
* Evaluates a set of bot capability signals against the ATF trigger model
|
|
5
|
+
* and returns a machine-readable adoption recommendation.
|
|
6
|
+
*
|
|
7
|
+
* Pure, deterministic, read-only. No side effects, no network calls.
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* - evaluateAdoption(signals) — core evaluation function
|
|
11
|
+
* - TRIGGER_MODEL — frozen trigger definitions
|
|
12
|
+
* - INSTALL_GUIDANCE — frozen install/enable guidance
|
|
13
|
+
* - SAFE_CALL_SEQUENCE — frozen default call sequence
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Trigger model — frozen, deterministic
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** @typedef {"critical"|"high"|"medium"|"low"} Severity */
|
|
21
|
+
/** @typedef {"free_core"|"advanced_bot"} Tier */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} Trigger
|
|
25
|
+
* @property {string} trigger_id
|
|
26
|
+
* @property {string} title
|
|
27
|
+
* @property {Severity} severity
|
|
28
|
+
* @property {string} description
|
|
29
|
+
* @property {string} machine_check
|
|
30
|
+
* @property {string} rationale
|
|
31
|
+
* @property {string} recommendation
|
|
32
|
+
* @property {Tier} recommended_tier
|
|
33
|
+
* @property {string[]} next_actions
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Severity ordering for comparison. */
|
|
37
|
+
const SEVERITY_RANK = Object.freeze({
|
|
38
|
+
critical: 4,
|
|
39
|
+
high: 3,
|
|
40
|
+
medium: 2,
|
|
41
|
+
low: 1,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** All known trigger definitions. */
|
|
45
|
+
export const TRIGGER_MODEL = Object.freeze([
|
|
46
|
+
Object.freeze({
|
|
47
|
+
trigger_id: "bot_handles_real_capital",
|
|
48
|
+
title: "Bot Handles Real Capital",
|
|
49
|
+
severity: "critical",
|
|
50
|
+
description:
|
|
51
|
+
"Bot sends transactions involving real tokens with monetary value",
|
|
52
|
+
machine_check:
|
|
53
|
+
"bot submits on-chain transactions with non-zero token amounts",
|
|
54
|
+
rationale:
|
|
55
|
+
"Any bot moving real capital without policy enforcement risks " +
|
|
56
|
+
"unbounded financial loss.",
|
|
57
|
+
recommendation:
|
|
58
|
+
"Install ATF immediately. Enable atf_protect_intent before " +
|
|
59
|
+
"every on-chain transaction.",
|
|
60
|
+
recommended_tier: "free_core",
|
|
61
|
+
next_actions: Object.freeze([
|
|
62
|
+
"npm install -g @trucore/atf",
|
|
63
|
+
"atf health",
|
|
64
|
+
"atf bot preflight",
|
|
65
|
+
"echo '{...}' | atf bot protect --stdin",
|
|
66
|
+
]),
|
|
67
|
+
}),
|
|
68
|
+
Object.freeze({
|
|
69
|
+
trigger_id: "bot_signs_or_submits_transactions",
|
|
70
|
+
title: "Bot Signs or Submits Transactions",
|
|
71
|
+
severity: "critical",
|
|
72
|
+
description:
|
|
73
|
+
"Bot has signing authority and broadcasts transactions to a blockchain",
|
|
74
|
+
machine_check:
|
|
75
|
+
"bot calls sendTransaction, signTransaction, or equivalent RPC method",
|
|
76
|
+
rationale:
|
|
77
|
+
"Signing authority without enforcement middleware means the bot " +
|
|
78
|
+
"can submit any transaction without constraint.",
|
|
79
|
+
recommendation:
|
|
80
|
+
"Install ATF immediately. Place atf_protect_intent before " +
|
|
81
|
+
"every sign-and-submit call.",
|
|
82
|
+
recommended_tier: "free_core",
|
|
83
|
+
next_actions: Object.freeze([
|
|
84
|
+
"npm install -g @trucore/atf",
|
|
85
|
+
"atf health",
|
|
86
|
+
"atf bot preflight",
|
|
87
|
+
"echo '{...}' | atf bot protect --stdin",
|
|
88
|
+
]),
|
|
89
|
+
}),
|
|
90
|
+
Object.freeze({
|
|
91
|
+
trigger_id: "bot_retries_automatically",
|
|
92
|
+
title: "Bot Auto-Retries Failed Transactions",
|
|
93
|
+
severity: "high",
|
|
94
|
+
description:
|
|
95
|
+
"Bot retries failed transactions without human approval between attempts",
|
|
96
|
+
machine_check: "bot retry loop runs without human-in-the-loop gate",
|
|
97
|
+
rationale:
|
|
98
|
+
"Unguarded retry loops can escalate errors — e.g. increasing " +
|
|
99
|
+
"slippage or resubmitting to a failing venue.",
|
|
100
|
+
recommendation:
|
|
101
|
+
"Install ATF. Wrap each retry in atf_protect_intent.",
|
|
102
|
+
recommended_tier: "free_core",
|
|
103
|
+
next_actions: Object.freeze([
|
|
104
|
+
"npm install -g @trucore/atf",
|
|
105
|
+
"atf bot preflight",
|
|
106
|
+
"Wrap retry loop: each attempt calls atf bot protect --stdin",
|
|
107
|
+
"Save receipt for every attempt",
|
|
108
|
+
]),
|
|
109
|
+
}),
|
|
110
|
+
Object.freeze({
|
|
111
|
+
trigger_id: "bot_lacks_audit_trail",
|
|
112
|
+
title: "Bot Lacks Tamper-Evident Audit Trail",
|
|
113
|
+
severity: "high",
|
|
114
|
+
description:
|
|
115
|
+
"Bot cannot produce tamper-evident receipts or explain why " +
|
|
116
|
+
"a transaction was approved",
|
|
117
|
+
machine_check:
|
|
118
|
+
"no tamper-evident receipt generation per transaction decision",
|
|
119
|
+
rationale:
|
|
120
|
+
"Without receipts, operators cannot prove what the bot approved, " +
|
|
121
|
+
"when, or why.",
|
|
122
|
+
recommendation:
|
|
123
|
+
"Install ATF. Save all receipts. Use atf_verify_receipt " +
|
|
124
|
+
"for integrity checks.",
|
|
125
|
+
recommended_tier: "free_core",
|
|
126
|
+
next_actions: Object.freeze([
|
|
127
|
+
"npm install -g @trucore/atf",
|
|
128
|
+
"echo '{...}' | atf bot protect --stdin",
|
|
129
|
+
"Save receipt JSON to ./atf_receipts/",
|
|
130
|
+
"atf receipts verify --file receipt.json",
|
|
131
|
+
]),
|
|
132
|
+
}),
|
|
133
|
+
Object.freeze({
|
|
134
|
+
trigger_id: "bot_operates_without_spending_limits",
|
|
135
|
+
title: "Bot Has No Spending Limits",
|
|
136
|
+
severity: "high",
|
|
137
|
+
description:
|
|
138
|
+
"No policy caps on notional size, leverage, or slippage",
|
|
139
|
+
machine_check:
|
|
140
|
+
"no max_notional, max_leverage, or max_slippage configuration",
|
|
141
|
+
rationale:
|
|
142
|
+
"Unbounded parameters allow catastrophic single-transaction losses.",
|
|
143
|
+
recommendation:
|
|
144
|
+
"Install ATF. Configure spending limits in policy YAML.",
|
|
145
|
+
recommended_tier: "free_core",
|
|
146
|
+
next_actions: Object.freeze([
|
|
147
|
+
"npm install -g @trucore/atf",
|
|
148
|
+
"Configure policy YAML with spending limits",
|
|
149
|
+
"atf bot preflight",
|
|
150
|
+
]),
|
|
151
|
+
}),
|
|
152
|
+
Object.freeze({
|
|
153
|
+
trigger_id: "operator_cannot_explain_failures",
|
|
154
|
+
title: "Operator Cannot Explain Transaction Failures",
|
|
155
|
+
severity: "medium",
|
|
156
|
+
description:
|
|
157
|
+
"Operator cannot determine why a transaction was blocked or allowed",
|
|
158
|
+
machine_check:
|
|
159
|
+
"no structured reason codes in transaction decision logs",
|
|
160
|
+
rationale:
|
|
161
|
+
"Silent failures erode operator trust and make post-mortems impossible.",
|
|
162
|
+
recommendation:
|
|
163
|
+
"Install ATF. Use atf_tx_explain after any DENY.",
|
|
164
|
+
recommended_tier: "free_core",
|
|
165
|
+
next_actions: Object.freeze([
|
|
166
|
+
"npm install -g @trucore/atf",
|
|
167
|
+
"atf bot protect --stdin for every intent",
|
|
168
|
+
"On DENY: atf tx explain --reason-codes CODES",
|
|
169
|
+
]),
|
|
170
|
+
}),
|
|
171
|
+
Object.freeze({
|
|
172
|
+
trigger_id: "bot_uses_multiple_dexes_or_protocols",
|
|
173
|
+
title: "Bot Uses Multiple DEXes or Protocols",
|
|
174
|
+
severity: "medium",
|
|
175
|
+
description:
|
|
176
|
+
"Bot routes through multiple DeFi protocols, DEXes, or lending venues",
|
|
177
|
+
machine_check:
|
|
178
|
+
"bot interacts with 2 or more DeFi protocols or venues",
|
|
179
|
+
rationale:
|
|
180
|
+
"Multi-venue bots have a larger attack surface and more complex " +
|
|
181
|
+
"failure modes.",
|
|
182
|
+
recommendation:
|
|
183
|
+
"Install ATF. Configure allowed_venues in policy YAML.",
|
|
184
|
+
recommended_tier: "free_core",
|
|
185
|
+
next_actions: Object.freeze([
|
|
186
|
+
"npm install -g @trucore/atf",
|
|
187
|
+
"Configure allowed_venues per category in policy YAML",
|
|
188
|
+
"atf bot preflight",
|
|
189
|
+
]),
|
|
190
|
+
}),
|
|
191
|
+
Object.freeze({
|
|
192
|
+
trigger_id: "bot_runs_unattended",
|
|
193
|
+
title: "Bot Runs Unattended",
|
|
194
|
+
severity: "medium",
|
|
195
|
+
description:
|
|
196
|
+
"Bot executes for extended periods without operator supervision",
|
|
197
|
+
machine_check:
|
|
198
|
+
"bot session duration exceeds 1 hour without human check-in",
|
|
199
|
+
rationale:
|
|
200
|
+
"Unattended bots can accumulate risk without oversight.",
|
|
201
|
+
recommendation:
|
|
202
|
+
"Install ATF. Run atf_bot_preflight at session start.",
|
|
203
|
+
recommended_tier: "free_core",
|
|
204
|
+
next_actions: Object.freeze([
|
|
205
|
+
"npm install -g @trucore/atf",
|
|
206
|
+
"atf bot preflight at session start",
|
|
207
|
+
"atf report savings at session end",
|
|
208
|
+
]),
|
|
209
|
+
}),
|
|
210
|
+
Object.freeze({
|
|
211
|
+
trigger_id: "bot_needs_operator_controls",
|
|
212
|
+
title: "Bot Needs Richer Operator Controls",
|
|
213
|
+
severity: "low",
|
|
214
|
+
description:
|
|
215
|
+
"Operator requires multi-bot oversight, governor controls, " +
|
|
216
|
+
"escalation, or circuit-breaker capabilities",
|
|
217
|
+
machine_check:
|
|
218
|
+
"operator requests session model, multi-bot governance, " +
|
|
219
|
+
"kill-switch, or escalation workflows",
|
|
220
|
+
rationale:
|
|
221
|
+
"Professional bot operators need richer controls. The ATF " +
|
|
222
|
+
"Advanced Bot Package provides priority support and upcoming " +
|
|
223
|
+
"operator-grade governance.",
|
|
224
|
+
recommendation:
|
|
225
|
+
"Install ATF free core first. Evaluate whether advanced_bot " +
|
|
226
|
+
"package is needed.",
|
|
227
|
+
recommended_tier: "advanced_bot",
|
|
228
|
+
next_actions: Object.freeze([
|
|
229
|
+
"npm install -g @trucore/atf",
|
|
230
|
+
"Verify free core meets current needs",
|
|
231
|
+
"Review billing.json for advanced_bot pricing",
|
|
232
|
+
"Contact support@trucore.xyz for custom policy consulting",
|
|
233
|
+
]),
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Install guidance — frozen, deterministic
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
export const INSTALL_GUIDANCE = Object.freeze({
|
|
242
|
+
package_identity: Object.freeze({
|
|
243
|
+
npm_cli: "@trucore/atf",
|
|
244
|
+
npm_openclaw_plugin: "@trucore/openclaw-atf",
|
|
245
|
+
plugin_id: "trucore-atf",
|
|
246
|
+
}),
|
|
247
|
+
install_command: "npm install -g @trucore/atf",
|
|
248
|
+
verify_commands: Object.freeze(["atf --version", "atf health"]),
|
|
249
|
+
minimal_config_path:
|
|
250
|
+
"Zero-config first — ATF uses safe defaults. " +
|
|
251
|
+
"Add policy YAML when ready to customize.",
|
|
252
|
+
zero_config_available: true,
|
|
253
|
+
disable_command: "npm uninstall -g @trucore/atf",
|
|
254
|
+
restart_needed: false,
|
|
255
|
+
what_is_automated: Object.freeze([
|
|
256
|
+
"CLI installation via npm",
|
|
257
|
+
"Health and preflight checks",
|
|
258
|
+
"Policy evaluation against configured YAML",
|
|
259
|
+
"Receipt generation and verification",
|
|
260
|
+
"Savings report generation",
|
|
261
|
+
]),
|
|
262
|
+
what_requires_human_action: Object.freeze([
|
|
263
|
+
"Writing and maintaining policy YAML",
|
|
264
|
+
"Integrating atf bot protect into bot execution loop",
|
|
265
|
+
"Reviewing and acting on deny decisions",
|
|
266
|
+
"Advanced package payment and activation",
|
|
267
|
+
]),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Default safe call sequence — frozen, deterministic
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export const SAFE_CALL_SEQUENCE = Object.freeze([
|
|
275
|
+
Object.freeze({
|
|
276
|
+
order: 1,
|
|
277
|
+
tool: "atf_health",
|
|
278
|
+
purpose: "Verify ATF CLI or API is reachable",
|
|
279
|
+
when: "Once at bot startup",
|
|
280
|
+
}),
|
|
281
|
+
Object.freeze({
|
|
282
|
+
order: 2,
|
|
283
|
+
tool: "atf_bot_preflight",
|
|
284
|
+
purpose: "Pre-session go/no-go check",
|
|
285
|
+
when: "Once per session, after health passes",
|
|
286
|
+
}),
|
|
287
|
+
Object.freeze({
|
|
288
|
+
order: 3,
|
|
289
|
+
tool: "atf_protect_intent",
|
|
290
|
+
purpose: "Evaluate each DeFi intent against policy",
|
|
291
|
+
when: "Before every on-chain transaction",
|
|
292
|
+
}),
|
|
293
|
+
Object.freeze({
|
|
294
|
+
order: 4,
|
|
295
|
+
tool: "atf_tx_explain",
|
|
296
|
+
purpose: "Explain DENY decisions with remediation",
|
|
297
|
+
when: "After any DENY or unexpected WARN",
|
|
298
|
+
}),
|
|
299
|
+
Object.freeze({
|
|
300
|
+
order: 5,
|
|
301
|
+
tool: "atf_verify_receipt",
|
|
302
|
+
purpose: "Verify receipt integrity",
|
|
303
|
+
when: "Periodically or when auditing past decisions",
|
|
304
|
+
}),
|
|
305
|
+
Object.freeze({
|
|
306
|
+
order: 6,
|
|
307
|
+
tool: "atf_report_savings",
|
|
308
|
+
purpose: "Generate receipt-backed savings report",
|
|
309
|
+
when: "End of session or daily",
|
|
310
|
+
}),
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Core evaluation logic — pure, deterministic
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Evaluate bot capability signals against the ATF trigger model.
|
|
319
|
+
*
|
|
320
|
+
* @param {Record<string, boolean>} signals
|
|
321
|
+
* Keys are trigger_id values (e.g. "bot_handles_real_capital").
|
|
322
|
+
* Values are boolean: true = trigger condition is met.
|
|
323
|
+
* Unknown keys are silently ignored.
|
|
324
|
+
* If signals is empty/null/undefined, returns install_needed=false.
|
|
325
|
+
*
|
|
326
|
+
* @returns {{
|
|
327
|
+
* install_needed: boolean,
|
|
328
|
+
* reason_codes: string[],
|
|
329
|
+
* matched_triggers: Array<{trigger_id: string, title: string, severity: string, recommendation: string, recommended_tier: string}>,
|
|
330
|
+
* highest_severity: string|null,
|
|
331
|
+
* recommended_tier: "free_core"|"advanced_bot"|"none",
|
|
332
|
+
* recommended_call_sequence: typeof SAFE_CALL_SEQUENCE,
|
|
333
|
+
* install_guidance: typeof INSTALL_GUIDANCE,
|
|
334
|
+
* config_path: string,
|
|
335
|
+
* billing_needed: boolean,
|
|
336
|
+
* next_actions: string[],
|
|
337
|
+
* severity_message: string,
|
|
338
|
+
* }}
|
|
339
|
+
*/
|
|
340
|
+
export function evaluateAdoption(signals) {
|
|
341
|
+
const safeSignals =
|
|
342
|
+
signals && typeof signals === "object" ? signals : {};
|
|
343
|
+
|
|
344
|
+
// Match triggers
|
|
345
|
+
const matched = [];
|
|
346
|
+
for (const trigger of TRIGGER_MODEL) {
|
|
347
|
+
if (safeSignals[trigger.trigger_id] === true) {
|
|
348
|
+
matched.push(trigger);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (matched.length === 0) {
|
|
353
|
+
return {
|
|
354
|
+
install_needed: false,
|
|
355
|
+
reason_codes: [],
|
|
356
|
+
matched_triggers: [],
|
|
357
|
+
highest_severity: null,
|
|
358
|
+
recommended_tier: "none",
|
|
359
|
+
recommended_call_sequence: SAFE_CALL_SEQUENCE,
|
|
360
|
+
install_guidance: INSTALL_GUIDANCE,
|
|
361
|
+
config_path: "policy.yaml (when ready)",
|
|
362
|
+
billing_needed: false,
|
|
363
|
+
next_actions: [
|
|
364
|
+
"Monitor for future trigger activation",
|
|
365
|
+
"Re-evaluate when bot capabilities change",
|
|
366
|
+
],
|
|
367
|
+
severity_message:
|
|
368
|
+
"ATF adoption optional — monitor for future trigger activation",
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Determine highest severity
|
|
373
|
+
let highestRank = 0;
|
|
374
|
+
let highestSeverity = "low";
|
|
375
|
+
for (const t of matched) {
|
|
376
|
+
const rank = SEVERITY_RANK[t.severity] ?? 0;
|
|
377
|
+
if (rank > highestRank) {
|
|
378
|
+
highestRank = rank;
|
|
379
|
+
highestSeverity = t.severity;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Determine recommended tier (advanced_bot wins if any matched trigger recommends it)
|
|
384
|
+
const needsAdvanced = matched.some(
|
|
385
|
+
(t) => t.recommended_tier === "advanced_bot",
|
|
386
|
+
);
|
|
387
|
+
const recommendedTier = needsAdvanced ? "advanced_bot" : "free_core";
|
|
388
|
+
|
|
389
|
+
// Collect unique next_actions from matched triggers
|
|
390
|
+
const actionSet = new Set();
|
|
391
|
+
for (const t of matched) {
|
|
392
|
+
for (const a of t.next_actions) {
|
|
393
|
+
actionSet.add(a);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Severity message
|
|
398
|
+
const severityMessages = {
|
|
399
|
+
critical: "Immediate ATF adoption strongly recommended",
|
|
400
|
+
high: "ATF adoption recommended before next production deployment",
|
|
401
|
+
medium: "ATF adoption recommended for operational maturity",
|
|
402
|
+
low: "ATF adoption optional — evaluate advanced package when ready",
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
install_needed: true,
|
|
407
|
+
reason_codes: matched.map((t) => t.trigger_id),
|
|
408
|
+
matched_triggers: matched.map((t) => ({
|
|
409
|
+
trigger_id: t.trigger_id,
|
|
410
|
+
title: t.title,
|
|
411
|
+
severity: t.severity,
|
|
412
|
+
recommendation: t.recommendation,
|
|
413
|
+
recommended_tier: t.recommended_tier,
|
|
414
|
+
})),
|
|
415
|
+
highest_severity: highestSeverity,
|
|
416
|
+
recommended_tier: recommendedTier,
|
|
417
|
+
recommended_call_sequence: SAFE_CALL_SEQUENCE,
|
|
418
|
+
install_guidance: INSTALL_GUIDANCE,
|
|
419
|
+
config_path: "policy.yaml (when ready)",
|
|
420
|
+
billing_needed: recommendedTier === "advanced_bot",
|
|
421
|
+
next_actions: [...actionSet],
|
|
422
|
+
severity_message:
|
|
423
|
+
severityMessages[highestSeverity] ?? severityMessages.low,
|
|
424
|
+
};
|
|
425
|
+
}
|
package/src/backend.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backend.mjs — Deterministic backend selection for ATF OpenClaw plugin
|
|
3
|
+
*
|
|
4
|
+
* Resolves which ATF backend (CLI or API) to use for a given request,
|
|
5
|
+
* with full diagnostics about preference, availability, and fallback.
|
|
6
|
+
*
|
|
7
|
+
* Exports:
|
|
8
|
+
* - resolveBackend(cfg, probes) — pick effective backend + diagnostics
|
|
9
|
+
* - probeCliAvailable(cli) — check CLI reachability
|
|
10
|
+
* - probeApiAvailable(baseUrl) — check API reachability
|
|
11
|
+
* - BACKEND_STATUS — status enum
|
|
12
|
+
*
|
|
13
|
+
* Uses ONLY built-in Node modules. No dependencies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Status enum
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** @enum {string} */
|
|
26
|
+
export const BACKEND_STATUS = Object.freeze({
|
|
27
|
+
OK: "ok",
|
|
28
|
+
DEGRADED: "degraded",
|
|
29
|
+
MISCONFIGURED: "misconfigured",
|
|
30
|
+
UNAVAILABLE: "unavailable",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Probes
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check whether the ATF CLI is reachable and return version info.
|
|
39
|
+
* Never throws.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} cli CLI command name or path.
|
|
42
|
+
* @returns {Promise<{available: boolean, version?: string, reason?: string}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function probeCliAvailable(cli) {
|
|
45
|
+
if (!cli || typeof cli !== "string") {
|
|
46
|
+
return { available: false, reason: "atfCli not configured" };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const { stdout, stderr } = await execFileAsync(cli, ["--version"], {
|
|
50
|
+
timeout: 10_000,
|
|
51
|
+
maxBuffer: 1024 * 1024,
|
|
52
|
+
shell: false,
|
|
53
|
+
});
|
|
54
|
+
const version = (stdout ?? "").trim();
|
|
55
|
+
if (version.length > 0) {
|
|
56
|
+
return { available: true, version };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
available: false,
|
|
60
|
+
reason: `CLI returned empty version: ${(stderr ?? "").trim()}`,
|
|
61
|
+
};
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return {
|
|
64
|
+
available: false,
|
|
65
|
+
reason: err?.code === "ENOENT"
|
|
66
|
+
? `CLI not found: '${cli}' is not installed or not in PATH`
|
|
67
|
+
: `CLI probe failed: ${err?.message ?? String(err)}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check whether the ATF API is reachable via /health.
|
|
74
|
+
* Never throws.
|
|
75
|
+
*
|
|
76
|
+
* @param {string|null|undefined} baseUrl ATF API base URL.
|
|
77
|
+
* @returns {Promise<{available: boolean, status?: number, url?: string, reason?: string}>}
|
|
78
|
+
*/
|
|
79
|
+
export async function probeApiAvailable(baseUrl) {
|
|
80
|
+
if (!baseUrl || typeof baseUrl !== "string") {
|
|
81
|
+
return { available: false, reason: "atfBaseUrl not configured" };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`${baseUrl}/health`, {
|
|
85
|
+
headers: { "User-Agent": "openclaw-atf-plugin/doctor" },
|
|
86
|
+
signal: AbortSignal.timeout(5_000),
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
available: res.ok,
|
|
90
|
+
status: res.status,
|
|
91
|
+
url: baseUrl,
|
|
92
|
+
...(res.ok ? {} : { reason: `HTTP ${res.status}` }),
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
available: false,
|
|
97
|
+
url: baseUrl,
|
|
98
|
+
reason: `API probe failed: ${err?.message ?? String(err)}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Backend resolution
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {object} BackendResolution
|
|
109
|
+
* @property {string} preferred_backend What the config says to prefer.
|
|
110
|
+
* @property {string} effective_backend What will actually be used.
|
|
111
|
+
* @property {boolean} fallback_occurred True if effective != preferred.
|
|
112
|
+
* @property {string|null} fallback_reason Why fallback happened, if it did.
|
|
113
|
+
* @property {{available: boolean, version?: string, reason?: string}} cli
|
|
114
|
+
* @property {{available: boolean, status?: number, url?: string, reason?: string}} api
|
|
115
|
+
* @property {string} status One of BACKEND_STATUS values.
|
|
116
|
+
* @property {string[]} warnings Operator-facing warnings.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the effective backend with full diagnostics.
|
|
121
|
+
*
|
|
122
|
+
* Behavior matrix:
|
|
123
|
+
* prefer=cli, CLI available → effective=cli, status=ok
|
|
124
|
+
* prefer=cli, CLI unavailable, API available → effective=api, status=degraded
|
|
125
|
+
* prefer=api, API available → effective=api, status=ok
|
|
126
|
+
* prefer=api, API unavailable, CLI available → effective=cli, status=degraded
|
|
127
|
+
* prefer=api, no atfBaseUrl → misconfigured
|
|
128
|
+
* neither available → unavailable
|
|
129
|
+
*
|
|
130
|
+
* @param {Record<string, unknown>} cfg Resolved plugin config.
|
|
131
|
+
* @param {{ cli?: object, api?: object }} [probes] Optional pre-computed probes.
|
|
132
|
+
* @returns {Promise<BackendResolution>}
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveBackend(cfg, probes = {}) {
|
|
135
|
+
const preferred = cfg?.prefer === "api" ? "api" : "cli";
|
|
136
|
+
const cli = probes.cli ?? await probeCliAvailable(cfg?.atfCli ?? "atf");
|
|
137
|
+
const api = probes.api ?? await probeApiAvailable(cfg?.atfBaseUrl);
|
|
138
|
+
|
|
139
|
+
/** @type {string[]} */
|
|
140
|
+
const warnings = [];
|
|
141
|
+
let effective = preferred;
|
|
142
|
+
let fallbackOccurred = false;
|
|
143
|
+
let fallbackReason = null;
|
|
144
|
+
let status = BACKEND_STATUS.OK;
|
|
145
|
+
|
|
146
|
+
// Detect misconfiguration: prefer=api but no URL
|
|
147
|
+
if (preferred === "api" && !cfg?.atfBaseUrl) {
|
|
148
|
+
warnings.push(
|
|
149
|
+
"prefer=api but atfBaseUrl is not configured. " +
|
|
150
|
+
"Set atfBaseUrl in plugin config or switch to prefer=cli.",
|
|
151
|
+
);
|
|
152
|
+
status = BACKEND_STATUS.MISCONFIGURED;
|
|
153
|
+
// Fall back to CLI if available
|
|
154
|
+
if (cli.available) {
|
|
155
|
+
effective = "cli";
|
|
156
|
+
fallbackOccurred = true;
|
|
157
|
+
fallbackReason = "atfBaseUrl not configured; fell back to CLI";
|
|
158
|
+
} else {
|
|
159
|
+
effective = "none";
|
|
160
|
+
status = BACKEND_STATUS.UNAVAILABLE;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
preferred_backend: preferred,
|
|
164
|
+
effective_backend: effective,
|
|
165
|
+
fallback_occurred: fallbackOccurred,
|
|
166
|
+
fallback_reason: fallbackReason,
|
|
167
|
+
cli,
|
|
168
|
+
api,
|
|
169
|
+
status,
|
|
170
|
+
warnings,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (preferred === "cli") {
|
|
175
|
+
if (cli.available) {
|
|
176
|
+
effective = "cli";
|
|
177
|
+
status = BACKEND_STATUS.OK;
|
|
178
|
+
} else if (api.available) {
|
|
179
|
+
effective = "api";
|
|
180
|
+
fallbackOccurred = true;
|
|
181
|
+
fallbackReason = `CLI unavailable (${cli.reason ?? "unknown"}); fell back to API`;
|
|
182
|
+
status = BACKEND_STATUS.DEGRADED;
|
|
183
|
+
warnings.push(
|
|
184
|
+
`prefer=cli but CLI unavailable: ${cli.reason ?? "unknown"}. Using API fallback.`,
|
|
185
|
+
);
|
|
186
|
+
} else {
|
|
187
|
+
effective = "none";
|
|
188
|
+
status = BACKEND_STATUS.UNAVAILABLE;
|
|
189
|
+
warnings.push(
|
|
190
|
+
`Both CLI and API are unavailable. CLI: ${cli.reason ?? "unknown"}. API: ${api.reason ?? "unknown"}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
// preferred === "api"
|
|
195
|
+
if (api.available) {
|
|
196
|
+
effective = "api";
|
|
197
|
+
status = BACKEND_STATUS.OK;
|
|
198
|
+
} else if (cli.available) {
|
|
199
|
+
effective = "cli";
|
|
200
|
+
fallbackOccurred = true;
|
|
201
|
+
fallbackReason = `API unavailable (${api.reason ?? "unknown"}); fell back to CLI`;
|
|
202
|
+
status = BACKEND_STATUS.DEGRADED;
|
|
203
|
+
warnings.push(
|
|
204
|
+
`prefer=api but API unavailable: ${api.reason ?? "unknown"}. Using CLI fallback.`,
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
effective = "none";
|
|
208
|
+
status = BACKEND_STATUS.UNAVAILABLE;
|
|
209
|
+
warnings.push(
|
|
210
|
+
`Both CLI and API are unavailable. CLI: ${cli.reason ?? "unknown"}. API: ${api.reason ?? "unknown"}.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Additional warnings
|
|
216
|
+
if (preferred === "cli" && !cli.available && !api.available) {
|
|
217
|
+
warnings.push("Install ATF CLI: npm install -g @trucore/atf");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
preferred_backend: preferred,
|
|
222
|
+
effective_backend: effective,
|
|
223
|
+
fallback_occurred: fallbackOccurred,
|
|
224
|
+
fallback_reason: fallbackReason,
|
|
225
|
+
cli,
|
|
226
|
+
api,
|
|
227
|
+
status,
|
|
228
|
+
warnings,
|
|
229
|
+
};
|
|
230
|
+
}
|