gemstack-ai 1.1.2 → 1.3.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/.agents/skills/gemstack-plan/SKILL.md +2 -1
- package/.agents/skills/gemstack-qa/SKILL.md +3 -0
- package/.agents/skills/gemstack-ship/SKILL.md +5 -1
- package/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/.gemstack/state.json +7 -8
- package/CHANGELOG.md +75 -0
- package/README.md +36 -0
- package/RELEASE_NOTES.md +61 -0
- package/docs/architecture-consistency.md +14 -2
- package/docs/spec-driven-development.md +26 -0
- package/{gemstack-ai-1.1.2.tgz → gemstack-ai-1.3.0.tgz} +0 -0
- package/handoff.md +30 -15
- package/package.json +2 -2
- package/specs/007-mechanical-test-matrix-closure-evidence/.gemstack.json +5 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/closure.json +59 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/plan.md +484 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/spec.md +597 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/tasks.md +536 -0
- package/specs/008-cost-provider-safety-gates/.gemstack.json +5 -0
- package/specs/008-cost-provider-safety-gates/closure.json +59 -0
- package/specs/008-cost-provider-safety-gates/plan.md +456 -0
- package/specs/008-cost-provider-safety-gates/spec.md +633 -0
- package/specs/008-cost-provider-safety-gates/tasks.md +635 -0
- package/specs/009-context-capsule/closure.json +59 -0
- package/specs/009-context-capsule/context-capsule.json +428 -0
- package/specs/009-context-capsule/plan.md +663 -0
- package/specs/009-context-capsule/spec.md +913 -0
- package/specs/009-context-capsule/tasks.md +720 -0
- package/specs/templates/plan.md +30 -0
- package/specs/templates/spec.md +18 -0
- package/specs/templates/tasks.md +9 -0
- package/src/cli.js +8 -0
- package/src/commands/collect.js +340 -0
- package/src/commands/context.js +95 -0
- package/src/commands/ship.js +79 -0
- package/src/commands/verify.js +182 -6
- package/src/lib/closure-context.js +453 -0
- package/src/lib/context-capsule.js +594 -0
- package/src/lib/cost-ledger.js +355 -0
- package/src/lib/provider-boundary.js +186 -0
- package/src/lib/provider-registry.js +265 -0
- package/src/lib/runner-adapters.js +347 -0
- package/src/lib/safety-gates.js +277 -0
- package/src/lib/test-matrix.js +187 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { createFinding } = require('./findings');
|
|
4
|
+
|
|
5
|
+
const VALID_COST_STATES = ['FREE', 'BILLABLE', 'POTENTIALLY_BILLABLE', 'UNKNOWN'];
|
|
6
|
+
const VALID_PROVIDER_TYPES = ['COMMERCIAL', 'LOCAL', 'MOCK'];
|
|
7
|
+
|
|
8
|
+
const FORBIDDEN_KEY_NAMES = new Set([
|
|
9
|
+
'apikey',
|
|
10
|
+
'api_key',
|
|
11
|
+
'token',
|
|
12
|
+
'accesstoken',
|
|
13
|
+
'access_token',
|
|
14
|
+
'bearertoken',
|
|
15
|
+
'bearer_token',
|
|
16
|
+
'secret',
|
|
17
|
+
'clientsecret',
|
|
18
|
+
'client_secret',
|
|
19
|
+
'authorization',
|
|
20
|
+
'private_key',
|
|
21
|
+
'privatekey',
|
|
22
|
+
'passwd',
|
|
23
|
+
'password',
|
|
24
|
+
'credentials'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const SECRET_VALUE_PATTERNS = [
|
|
28
|
+
/sk-[a-zA-Z0-9_-]{16,}/,
|
|
29
|
+
/ghp_[a-zA-Z0-9]{20,}/,
|
|
30
|
+
/Bearer\s+[a-zA-Z0-9._-]{16,}/i,
|
|
31
|
+
/AIza[0-9A-Za-z-_]{30,}/,
|
|
32
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Scans an arbitrary JSON-compatible data structure for forbidden credential keys and secret patterns.
|
|
37
|
+
*
|
|
38
|
+
* @param {*} data
|
|
39
|
+
* @param {string} [currentPath='']
|
|
40
|
+
* @returns {{ valid: boolean, violations: Array<{ path: string, reason: string }> }}
|
|
41
|
+
*/
|
|
42
|
+
function auditSecretsForbidden(data, currentPath = '') {
|
|
43
|
+
const violations = [];
|
|
44
|
+
|
|
45
|
+
function walk(node, p) {
|
|
46
|
+
if (!node || typeof node !== 'object') {
|
|
47
|
+
if (typeof node === 'string') {
|
|
48
|
+
for (const pattern of SECRET_VALUE_PATTERNS) {
|
|
49
|
+
if (pattern.test(node)) {
|
|
50
|
+
violations.push({
|
|
51
|
+
path: p,
|
|
52
|
+
reason: 'Forbidden secret pattern detected matching ' + pattern.toString()
|
|
53
|
+
});
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(node)) {
|
|
62
|
+
for (let i = 0; i < node.length; i++) {
|
|
63
|
+
walk(node[i], p + '[' + i + ']');
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const key of Object.keys(node)) {
|
|
69
|
+
const normalizedKey = key.toLowerCase().replace(/[-_]/g, '');
|
|
70
|
+
const rawNormalized = key.toLowerCase();
|
|
71
|
+
const childPath = p ? p + '.' + key : key;
|
|
72
|
+
|
|
73
|
+
if (FORBIDDEN_KEY_NAMES.has(rawNormalized) || FORBIDDEN_KEY_NAMES.has(normalizedKey)) {
|
|
74
|
+
violations.push({
|
|
75
|
+
path: childPath,
|
|
76
|
+
reason: 'Forbidden credential property name detected: "' + key + '"'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
walk(node[key], childPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
walk(data, currentPath);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
valid: violations.length === 0,
|
|
88
|
+
violations
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validates the structure and constraints of a cost-ledger data object.
|
|
94
|
+
*
|
|
95
|
+
* @param {*} data
|
|
96
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
97
|
+
*/
|
|
98
|
+
function validateLedgerSchema(data) {
|
|
99
|
+
const errors = [];
|
|
100
|
+
|
|
101
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
102
|
+
return { valid: false, errors: ['Cost ledger root must be a JSON object'] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (data.version !== 1) {
|
|
106
|
+
errors.push('Cost ledger version must be 1, got ' + JSON.stringify(data.version));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof data.currency !== 'string' || !data.currency.trim()) {
|
|
110
|
+
errors.push('Cost ledger requires non-empty string field "currency"');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!data.providers || typeof data.providers !== 'object' || Array.isArray(data.providers)) {
|
|
114
|
+
errors.push('Cost ledger requires "providers" map');
|
|
115
|
+
return { valid: false, errors };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const providerKeys = Object.keys(data.providers);
|
|
119
|
+
for (const providerId of providerKeys) {
|
|
120
|
+
const provider = data.providers[providerId];
|
|
121
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) {
|
|
122
|
+
errors.push('Provider "' + providerId + '" must be an object');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!VALID_PROVIDER_TYPES.includes(provider.type)) {
|
|
127
|
+
errors.push('Provider "' + providerId + '" has invalid type "' + provider.type + '". Must be one of: ' + VALID_PROVIDER_TYPES.join(', '));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (typeof provider.pricing_model !== 'string' || !provider.pricing_model.trim()) {
|
|
131
|
+
errors.push('Provider "' + providerId + '" requires string field "pricing_model"');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!provider.capabilities || typeof provider.capabilities !== 'object' || Array.isArray(provider.capabilities)) {
|
|
135
|
+
errors.push('Provider "' + providerId + '" requires "capabilities" map');
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const capId of Object.keys(provider.capabilities)) {
|
|
140
|
+
const cap = provider.capabilities[capId];
|
|
141
|
+
if (!cap || typeof cap !== 'object' || Array.isArray(cap)) {
|
|
142
|
+
errors.push('Provider "' + providerId + '" capability "' + capId + '" must be an object');
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!VALID_COST_STATES.includes(cap.cost_state)) {
|
|
147
|
+
errors.push('Capability "' + providerId + '.' + capId + '" has invalid cost_state "' + cap.cost_state + '". Must be one of: ' + VALID_COST_STATES.join(', '));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (typeof cap.estimated_unit_cost !== 'number' || Number.isNaN(cap.estimated_unit_cost) || cap.estimated_unit_cost < 0) {
|
|
151
|
+
errors.push('Capability "' + providerId + '.' + capId + '" requires non-negative number "estimated_unit_cost"');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (cap.freshness_date !== undefined) {
|
|
155
|
+
const parsedDate = Date.parse(cap.freshness_date);
|
|
156
|
+
if (Number.isNaN(parsedDate)) {
|
|
157
|
+
errors.push('Capability "' + providerId + '.' + capId + '" has invalid freshness_date: "' + cap.freshness_date + '"');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
valid: errors.length === 0,
|
|
165
|
+
errors
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Checks if a capability entry freshness date is older than maxAgeDays.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} capabilityEntry
|
|
173
|
+
* @param {number} [maxAgeDays=90]
|
|
174
|
+
* @param {Date} [referenceDate=new Date()]
|
|
175
|
+
* @returns {{ isStale: boolean, ageDays: number|null, message: string|null }}
|
|
176
|
+
*/
|
|
177
|
+
function checkStaleness(capabilityEntry, maxAgeDays = 90, referenceDate = new Date()) {
|
|
178
|
+
if (!capabilityEntry || !capabilityEntry.freshness_date) {
|
|
179
|
+
return {
|
|
180
|
+
isStale: true,
|
|
181
|
+
ageDays: null,
|
|
182
|
+
message: 'Capability entry missing required freshness_date'
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const freshnessTime = Date.parse(capabilityEntry.freshness_date);
|
|
187
|
+
if (Number.isNaN(freshnessTime)) {
|
|
188
|
+
return {
|
|
189
|
+
isStale: true,
|
|
190
|
+
ageDays: null,
|
|
191
|
+
message: 'Invalid freshness_date: "' + capabilityEntry.freshness_date + '"'
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const refTime = referenceDate.getTime();
|
|
196
|
+
const diffMs = refTime - freshnessTime;
|
|
197
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
198
|
+
|
|
199
|
+
if (diffDays > maxAgeDays) {
|
|
200
|
+
return {
|
|
201
|
+
isStale: true,
|
|
202
|
+
ageDays: diffDays,
|
|
203
|
+
message: 'Pricing assumption freshness date "' + capabilityEntry.freshness_date + '" is ' + diffDays + ' days old (exceeds ' + maxAgeDays + ' days limit)'
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
isStale: false,
|
|
209
|
+
ageDays: diffDays,
|
|
210
|
+
message: null
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Serializes cost ledger object with UTF-16 code-unit sorted keys deterministically.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} ledger
|
|
218
|
+
* @returns {string} Formatted JSON string
|
|
219
|
+
*/
|
|
220
|
+
function serializeCostLedger(ledger) {
|
|
221
|
+
function sortKeys(obj) {
|
|
222
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
|
|
223
|
+
const sorted = {};
|
|
224
|
+
const keys = Object.keys(obj).sort((a, b) => (a < b ? -1 : (a > b ? 1 : 0)));
|
|
225
|
+
for (const k of keys) {
|
|
226
|
+
sorted[k] = sortKeys(obj[k]);
|
|
227
|
+
}
|
|
228
|
+
return sorted;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return JSON.stringify(sortKeys(ledger), null, 2) + '\n';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Loads, parses, and validates cost-ledger.json from disk.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} filePath
|
|
238
|
+
* @param {object} [options={}]
|
|
239
|
+
* @param {Date} [options.referenceDate=new Date()]
|
|
240
|
+
* @param {number} [options.maxAgeDays=90]
|
|
241
|
+
* @returns {{ ledger: object|null, findings: object[], valid: boolean }}
|
|
242
|
+
*/
|
|
243
|
+
function loadCostLedger(filePath, options = {}) {
|
|
244
|
+
const { referenceDate = new Date(), maxAgeDays = 90 } = options;
|
|
245
|
+
const findings = [];
|
|
246
|
+
|
|
247
|
+
if (!fs.existsSync(filePath)) {
|
|
248
|
+
findings.push(createFinding({
|
|
249
|
+
code: 'COST_LEDGER_INVALID',
|
|
250
|
+
contractId: 'upgrade-c-cost-states',
|
|
251
|
+
phase: 'IMPLEMENTATION',
|
|
252
|
+
location: filePath,
|
|
253
|
+
details: 'Cost ledger file not found at: ' + filePath
|
|
254
|
+
}));
|
|
255
|
+
return { ledger: null, findings, valid: false };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let raw;
|
|
259
|
+
try {
|
|
260
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
261
|
+
} catch (err) {
|
|
262
|
+
findings.push(createFinding({
|
|
263
|
+
code: 'COST_LEDGER_INVALID',
|
|
264
|
+
contractId: 'upgrade-c-cost-states',
|
|
265
|
+
phase: 'IMPLEMENTATION',
|
|
266
|
+
location: filePath,
|
|
267
|
+
details: 'Failed to read cost ledger: ' + err.message
|
|
268
|
+
}));
|
|
269
|
+
return { ledger: null, findings, valid: false };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let data;
|
|
273
|
+
try {
|
|
274
|
+
data = JSON.parse(raw);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
findings.push(createFinding({
|
|
277
|
+
code: 'COST_LEDGER_INVALID',
|
|
278
|
+
contractId: 'upgrade-c-cost-states',
|
|
279
|
+
phase: 'IMPLEMENTATION',
|
|
280
|
+
location: filePath,
|
|
281
|
+
details: 'Cost ledger malformed JSON syntax: ' + err.message
|
|
282
|
+
}));
|
|
283
|
+
return { ledger: null, findings, valid: false };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 1. Audit Secrets (Strict Fail-Closed)
|
|
287
|
+
const secretsAudit = auditSecretsForbidden(data);
|
|
288
|
+
if (!secretsAudit.valid) {
|
|
289
|
+
for (const v of secretsAudit.violations) {
|
|
290
|
+
findings.push(createFinding({
|
|
291
|
+
code: 'COST_LEDGER_INVALID',
|
|
292
|
+
contractId: 'secrets-forbidden-in-safety-artifacts',
|
|
293
|
+
phase: 'IMPLEMENTATION',
|
|
294
|
+
location: filePath,
|
|
295
|
+
details: 'Secret violation at ' + v.path + ': ' + v.reason
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
return { ledger: null, findings, valid: false };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 2. Validate Schema
|
|
302
|
+
const schemaAudit = validateLedgerSchema(data);
|
|
303
|
+
if (!schemaAudit.valid) {
|
|
304
|
+
for (const err of schemaAudit.errors) {
|
|
305
|
+
findings.push(createFinding({
|
|
306
|
+
code: 'COST_LEDGER_INVALID',
|
|
307
|
+
contractId: 'upgrade-c-cost-states',
|
|
308
|
+
phase: 'IMPLEMENTATION',
|
|
309
|
+
location: filePath,
|
|
310
|
+
details: err
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
return { ledger: null, findings, valid: false };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 3. Staleness Evaluation (Warnings)
|
|
317
|
+
if (data.providers) {
|
|
318
|
+
for (const [providerId, provider] of Object.entries(data.providers)) {
|
|
319
|
+
if (provider.capabilities) {
|
|
320
|
+
for (const [capId, cap] of Object.entries(provider.capabilities)) {
|
|
321
|
+
const staleness = checkStaleness(cap, maxAgeDays, referenceDate);
|
|
322
|
+
if (staleness.isStale) {
|
|
323
|
+
const finding = createFinding({
|
|
324
|
+
code: 'STALE_PROVIDER_COST_ASSUMPTION',
|
|
325
|
+
contractId: 'upgrade-c-cost-states',
|
|
326
|
+
phase: 'IMPLEMENTATION',
|
|
327
|
+
location: filePath,
|
|
328
|
+
details: '[' + providerId + '.' + capId + '] ' + staleness.message
|
|
329
|
+
});
|
|
330
|
+
finding.is_blocking = false; // WARNING severity
|
|
331
|
+
findings.push(finding);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const hasBlockers = findings.some(f => f.is_blocking);
|
|
339
|
+
return {
|
|
340
|
+
ledger: hasBlockers ? null : data,
|
|
341
|
+
findings,
|
|
342
|
+
valid: !hasBlockers
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
VALID_COST_STATES,
|
|
348
|
+
VALID_PROVIDER_TYPES,
|
|
349
|
+
FORBIDDEN_KEY_NAMES,
|
|
350
|
+
auditSecretsForbidden,
|
|
351
|
+
validateLedgerSchema,
|
|
352
|
+
checkStaleness,
|
|
353
|
+
serializeCostLedger,
|
|
354
|
+
loadCostLedger
|
|
355
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const { PROVIDER_ID_REGEX } = require('./provider-registry');
|
|
3
|
+
const { evaluateProviderCapability, evaluateBillableAction } = require('./safety-gates');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes provider identity and prevents alias spoofing or path traversal.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} rawId
|
|
9
|
+
* @returns {string} Normalized provider slug
|
|
10
|
+
*/
|
|
11
|
+
function normalizeProviderId(rawId) {
|
|
12
|
+
if (typeof rawId !== 'string') {
|
|
13
|
+
throw new Error('Provider ID must be a string.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const trimmed = rawId.trim().toLowerCase();
|
|
17
|
+
if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
|
|
18
|
+
throw new Error('Provider ID contains illegal path characters: "' + rawId + '"');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!PROVIDER_ID_REGEX.test(trimmed)) {
|
|
22
|
+
throw new Error('Provider ID does not match canonical slug format: "' + rawId + '"');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return trimmed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a cryptographically bound execution token.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} providerId
|
|
32
|
+
* @param {string} capabilityId
|
|
33
|
+
* @param {string} actionId
|
|
34
|
+
* @param {object} [tokenPayload={}]
|
|
35
|
+
* @returns {string} SHA-256 bound token
|
|
36
|
+
*/
|
|
37
|
+
function createBoundToken(providerId, capabilityId, actionId, tokenPayload = {}) {
|
|
38
|
+
const payload = JSON.stringify({
|
|
39
|
+
providerId,
|
|
40
|
+
capabilityId,
|
|
41
|
+
actionId,
|
|
42
|
+
token: tokenPayload
|
|
43
|
+
});
|
|
44
|
+
return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Executes an action through the mandatory provider execution boundary.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} actionRequest
|
|
51
|
+
* @param {object} adapter
|
|
52
|
+
* @param {object} options
|
|
53
|
+
* @param {object} options.registry - ProviderRegistry
|
|
54
|
+
* @param {object} options.ledger - Cost ledger
|
|
55
|
+
* @returns {Promise<object>|object} Execution result
|
|
56
|
+
*/
|
|
57
|
+
function executeProviderAction(actionRequest, adapter, options = {}) {
|
|
58
|
+
if (!actionRequest || typeof actionRequest !== 'object') {
|
|
59
|
+
const err = new Error('Action request must be an object.');
|
|
60
|
+
err.reasonCode = 'UNDECLARED_BILLABLE_ACTION';
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 1. Normalize Provider Identity (Bypass & Alias Protection)
|
|
65
|
+
let normalizedProviderId;
|
|
66
|
+
try {
|
|
67
|
+
normalizedProviderId = normalizeProviderId(actionRequest.provider_id);
|
|
68
|
+
} catch (aliasErr) {
|
|
69
|
+
const err = new Error('Provider alias or ID rejected: ' + aliasErr.message);
|
|
70
|
+
err.reasonCode = 'UNKNOWN_PROVIDER_IDENTITY';
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const req = {
|
|
75
|
+
...actionRequest,
|
|
76
|
+
provider_id: normalizedProviderId
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 2. Gate 1: ProviderCapabilityGate
|
|
80
|
+
const capDecision = evaluateProviderCapability(req, options.registry, {
|
|
81
|
+
...options,
|
|
82
|
+
adapter
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!capDecision.authorized) {
|
|
86
|
+
const err = new Error('Provider capability denied: ' + capDecision.message);
|
|
87
|
+
err.reasonCode = capDecision.reasonCode;
|
|
88
|
+
err.decision = capDecision;
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Gate 2: BillableActionGate
|
|
93
|
+
const billDecision = evaluateBillableAction(req, options.ledger, options);
|
|
94
|
+
|
|
95
|
+
if (!billDecision.authorized) {
|
|
96
|
+
const err = new Error('Billable action denied: ' + billDecision.message);
|
|
97
|
+
err.reasonCode = billDecision.reasonCode;
|
|
98
|
+
err.decision = billDecision;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4. Bound Context Token
|
|
103
|
+
const boundToken = createBoundToken(
|
|
104
|
+
normalizedProviderId,
|
|
105
|
+
req.capability_id,
|
|
106
|
+
req.action_id,
|
|
107
|
+
req.authorization_token
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const executionContext = {
|
|
111
|
+
provider_id: normalizedProviderId,
|
|
112
|
+
capability_id: req.capability_id,
|
|
113
|
+
action_id: req.action_id,
|
|
114
|
+
boundToken,
|
|
115
|
+
authorized: true
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (!adapter || typeof adapter.execute !== 'function') {
|
|
119
|
+
const err = new Error('Adapter missing execute() method.');
|
|
120
|
+
err.reasonCode = 'PROVIDER_CAPABILITY_UNSUPPORTED';
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return adapter.execute(req, executionContext);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Executes an action with re-entrant fallback chains.
|
|
129
|
+
* Re-evaluates all gates independently for each candidate; authorization is NEVER inherited.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} primaryCandidate - { request, adapter }
|
|
132
|
+
* @param {Array<object>} fallbackCandidates - Array of { request, adapter }
|
|
133
|
+
* @param {object} options
|
|
134
|
+
* @returns {object} Execution result
|
|
135
|
+
*/
|
|
136
|
+
function executeWithFallback(primaryCandidate, fallbackCandidates = [], options = {}) {
|
|
137
|
+
try {
|
|
138
|
+
const result = executeProviderAction(primaryCandidate.request, primaryCandidate.adapter, options);
|
|
139
|
+
return {
|
|
140
|
+
success: true,
|
|
141
|
+
provider_id: primaryCandidate.request.provider_id,
|
|
142
|
+
result,
|
|
143
|
+
fallbackUsed: false
|
|
144
|
+
};
|
|
145
|
+
} catch (primaryErr) {
|
|
146
|
+
// Primary failed (network error, rate limit, denial, etc.)
|
|
147
|
+
// Iterate fallback candidates
|
|
148
|
+
for (let i = 0; i < fallbackCandidates.length; i++) {
|
|
149
|
+
const fb = fallbackCandidates[i];
|
|
150
|
+
|
|
151
|
+
// Re-enter gates independently!
|
|
152
|
+
// Invariant: fallback provider != inherited authorization
|
|
153
|
+
// If fallback candidate request does not satisfy spending gates independently -> emit PROVIDER_FALLBACK_UNAUTHORIZED
|
|
154
|
+
try {
|
|
155
|
+
const fbResult = executeProviderAction(fb.request, fb.adapter, options);
|
|
156
|
+
return {
|
|
157
|
+
success: true,
|
|
158
|
+
provider_id: fb.request.provider_id,
|
|
159
|
+
result: fbResult,
|
|
160
|
+
fallbackUsed: true,
|
|
161
|
+
fallbackIndex: i
|
|
162
|
+
};
|
|
163
|
+
} catch (fbErr) {
|
|
164
|
+
if (fbErr.reasonCode === 'BILLABLE_ACTION_UNAUTHORIZED' || fbErr.reasonCode === 'ENV_COMMERCIAL_DENIED') {
|
|
165
|
+
const fallbackErr = new Error('Fallback provider "' + (fb.request ? fb.request.provider_id : 'unknown') + '" failed authorization: ' + fbErr.message);
|
|
166
|
+
fallbackErr.reasonCode = 'PROVIDER_FALLBACK_UNAUTHORIZED';
|
|
167
|
+
fallbackErr.originalReason = fbErr.reasonCode;
|
|
168
|
+
throw fallbackErr;
|
|
169
|
+
}
|
|
170
|
+
// If it was an execution error (not authorization), continue to next fallback if available
|
|
171
|
+
if (i === fallbackCandidates.length - 1) {
|
|
172
|
+
throw fbErr;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw primaryErr;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
normalizeProviderId,
|
|
183
|
+
createBoundToken,
|
|
184
|
+
executeProviderAction,
|
|
185
|
+
executeWithFallback
|
|
186
|
+
};
|