@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -0,0 +1,335 @@
1
+ // OpenRouter chat completions for a service: its own key, a spend ceiling checked before every attempt, a usage receipt
2
+ // for every answered call, and a fallback model of another vendor. The twin of fabric_openrouter.py: the same rules, the
3
+ // same cases (test/fixtures/kit-openrouter-cases.json); the rules are written out in that file's docstring.
4
+ //
5
+ // const client = new Client({ ceilingUsd: 2, ledger: new JsonlUsageLedger(file), prices: pricesFromCatalog(catalog) });
6
+ // const answer = await client.chat([{ role: 'user', content: '...' }],
7
+ // { model: 'deepseek/deepseek-v4.1-flash', fallback: 'qwen/qwen3.8-flash', maxTokens: 800 });
8
+ // answer.content, answer.receipt.costUsd
9
+ //
10
+ // The key is read from the environment variable `keyEnv` names when a call is made, sent only in the Authorization
11
+ // header, never in an error. Before every attempt the window's spend (ledger receipts of the UTC day or month, plus
12
+ // calls in flight) plus this attempt's worst case (one token per two UTF-8 bytes of the messages as JSON, maxTokens of
13
+ // output, at the model's price) must stay within the ceiling. A 200 records one receipt; stop or tool_calls with
14
+ // content is an answer; error is retried on the model; length, content_filter and empty go to the fallback; 408, 429,
15
+ // 5xx and connection errors are retried within one deadline; 401, 402 and 403 stop at once. Attribution is off unless a
16
+ // setting is given (FAC-SEM-098). Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
17
+
18
+ import * as http from './fabric-http.mjs';
19
+ import { isObject } from './fabric-jsvalue.mjs';
20
+ import * as time from './fabric-time.mjs';
21
+ import { attributionHeaders, makeUsageReceipt } from './fabric-usage.mjs';
22
+
23
+ export const BASE_URL = 'https://openrouter.ai/api/v1';
24
+ export const ANSWERED = Object.freeze(['stop', 'tool_calls']);
25
+ const STOP_AT_ONCE = { 401: 'auth', 402: 'payment', 403: 'auth' };
26
+ const RESERVED_PARAMS = ['model', 'messages', 'max_tokens', 'max_completion_tokens', 'maxTokens', 'stream', 'usage'];
27
+ // Params that change what a call is billed for (other models, plugins, web search): the ceiling could not bound them.
28
+ const UNBOUNDED_PARAMS = ['models', 'route', 'plugins', 'web_search_options', 'n'];
29
+
30
+ export class OpenRouterError extends Error {
31
+ constructor(message, { kind, attempts = [] } = {}) {
32
+ super(message);
33
+ this.name = 'OpenRouterError';
34
+ this.kind = kind; // config, unpriced, ceiling, auth, payment, failed
35
+ this.attempts = [...attempts];
36
+ }
37
+ }
38
+
39
+ export const vendor = (model) => (String(model).includes('/') ? String(model).split('/', 1)[0] : '');
40
+
41
+ const DECIMAL = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/;
42
+
43
+ // A catalog price (a plain decimal string such as "0.000001", or a number) or null: blank, hex, a list, negative or
44
+ // not finite are null, exactly as fabric_openrouter.py reads them.
45
+ function price(value) {
46
+ let n;
47
+ if (typeof value === 'string') n = DECIMAL.test(value) ? Number(value) : Number.NaN;
48
+ else if (typeof value === 'number') n = value;
49
+ else return null;
50
+ return Number.isFinite(n) && n >= 0 ? n : null;
51
+ }
52
+
53
+ const count = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null); // as fabric_openrouter.py: 0 to 2**53 - 1
54
+
55
+ // Every message's content is text: a string, null, or parts that are all {type: 'text'}. An image, audio or file part is
56
+ // billed in ways a byte count cannot bound, so the client does not send it.
57
+ function textOnly(messages) {
58
+ return messages.every((m) => isObject(m) && (m.content === null || m.content === undefined || typeof m.content === 'string'
59
+ || (Array.isArray(m.content) && m.content.every((p) => isObject(p) && p.type === 'text'))));
60
+ }
61
+
62
+ // {model: {prompt, completion, request}} in USD per token (per request) from GET /api/v1/models; a model without a
63
+ // readable prompt or completion price is left out, so the client refuses it rather than guessing.
64
+ export function pricesFromCatalog(catalog) {
65
+ const out = {};
66
+ const rows = isObject(catalog) && Array.isArray(catalog.data) ? catalog.data : [];
67
+ for (const row of rows) {
68
+ if (!isObject(row) || typeof row.id !== 'string') continue;
69
+ const pricing = isObject(row.pricing) ? row.pricing : {};
70
+ const prompt = price(pricing.prompt);
71
+ const completion = price(pricing.completion);
72
+ if (prompt === null || completion === null) continue;
73
+ out[row.id] = { prompt, completion, request: price(pricing.request) ?? 0 };
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // One token per two UTF-8 bytes of what is sent as compact JSON: the messages and every param, all billed as input.
79
+ export const promptTokensBound = (value) => Math.ceil(http.jsonBytes(value).length / 2);
80
+
81
+ function checkAttribution(projectId, taskRunId) {
82
+ try {
83
+ makeUsageReceipt('openrouter', 'check', { inputTokens: 0, outputTokens: 0, projectId, taskRunId });
84
+ } catch (err) {
85
+ throw new OpenRouterError(err.message, { kind: 'config' });
86
+ }
87
+ }
88
+
89
+ export class Client {
90
+ constructor({ ceilingUsd, ledger, prices, keyEnv = 'OPENROUTER_API_KEY', window = 'day', baseUrl = BASE_URL,
91
+ attribution = null, projectId, unmeasuredCallUsd = null, deadlineS = 120, retries = 1, env = process.env,
92
+ transport, sleep, clock = () => performance.now() / 1000, now = () => Date.now() / 1000, rand = Math.random } = {}) {
93
+ if (typeof ceilingUsd !== 'number' || !Number.isFinite(ceilingUsd) || ceilingUsd < 0) {
94
+ throw new OpenRouterError('ceilingUsd is a finite number of dollars, 0 or more', { kind: 'config' });
95
+ }
96
+ if (!['day', 'month'].includes(window)) throw new OpenRouterError('window is day or month (UTC)', { kind: 'config' });
97
+ if (!ledger || typeof ledger.record !== 'function' || typeof ledger.receipts !== 'function') {
98
+ throw new OpenRouterError('ledger has record(receipt) and receipts(), as JsonlUsageLedger', { kind: 'config' });
99
+ }
100
+ if (!Number.isInteger(retries) || retries < 0 || retries > 5) {
101
+ throw new OpenRouterError('retries is an integer from 0 to 5', { kind: 'config' });
102
+ }
103
+ http.checkUrl(baseUrl);
104
+ checkAttribution(projectId, undefined);
105
+ Object.assign(this, { ceilingUsd, ledger, keyEnv, window, attribution, projectId, unmeasuredCallUsd, deadlineS, retries });
106
+ this.prices = Object.fromEntries(Object.entries(prices ?? {}).map(([k, v]) => [k, { ...v }]));
107
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
108
+ this.env = env;
109
+ this.transport = transport;
110
+ this.sleep = sleep ?? ((s) => new Promise((resolve) => setTimeout(resolve, s * 1000)));
111
+ this.clock = clock;
112
+ this.now = now;
113
+ this.rand = rand;
114
+ this.inFlight = 0;
115
+ this.unmeasuredWorst = new Map(); // `${at} ${model}` of an unmeasured receipt -> that attempt's worst case
116
+ }
117
+
118
+ priced(model) {
119
+ const p = this.prices[model];
120
+ if (!isObject(p)) return null;
121
+ const prompt = price(p.prompt);
122
+ const completion = price(p.completion);
123
+ if (prompt === null || completion === null) return null;
124
+ return { prompt, completion, request: price(p.request) ?? 0 };
125
+ }
126
+
127
+ worstCaseUsd(model, messages, maxTokens, params = {}) {
128
+ const p = this.priced(model);
129
+ if (p === null) return null;
130
+ return promptTokensBound({ ...params, messages }) * p.prompt + maxTokens * p.completion + p.request;
131
+ }
132
+
133
+ inWindow(at, today) {
134
+ const ms = time.parseRfc3339Ms(at);
135
+ if (Number.isNaN(ms)) return false;
136
+ const day = time.utcDay(ms);
137
+ return this.window === 'day' ? day === today : day.slice(0, 7) === today.slice(0, 7);
138
+ }
139
+
140
+ spentUsd({ unmeasuredUsd = 0 } = {}) {
141
+ const today = time.utcDay(this.now() * 1000);
142
+ let total = 0;
143
+ for (const r of this.ledger.receipts()) {
144
+ if (!isObject(r) || !this.inWindow(r.at, today)) continue;
145
+ if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) total += r.costUsd;
146
+ else total += this.unmeasuredWorst.get(`${r.at} ${r.model}`) ?? unmeasuredUsd; // known here, else unmeasuredUsd
147
+ }
148
+ return total;
149
+ }
150
+
151
+ reserve(model, messages, maxTokens, params) {
152
+ const worst = this.worstCaseUsd(model, messages, maxTokens, params);
153
+ if (worst === null) {
154
+ throw new OpenRouterError(`${model} has no price: its spend cannot be bounded, so it is not called`, { kind: 'unpriced' });
155
+ }
156
+ const unmeasured = this.unmeasuredCallUsd === null ? worst : Number(this.unmeasuredCallUsd);
157
+ const spent = this.spentUsd({ unmeasuredUsd: unmeasured }) + this.inFlight;
158
+ if (spent + worst > this.ceilingUsd) {
159
+ throw new OpenRouterError(`the ${this.window} ceiling of $${this.ceilingUsd.toFixed(4)} would be passed: spent `
160
+ + `$${spent.toFixed(4)}, this attempt up to $${worst.toFixed(4)}`, { kind: 'ceiling' });
161
+ }
162
+ this.inFlight += worst; // synchronous with the check: no other call can pass between them
163
+ return worst;
164
+ }
165
+
166
+ release(amount) { this.inFlight = Math.max(0, this.inFlight - amount); }
167
+
168
+ receipt(asked, body, taskRunId, worst) {
169
+ const served = typeof body.model === 'string' && body.model.trim() ? body.model : asked;
170
+ const usage = isObject(body.usage) ? body.usage : null;
171
+ const common = { at: time.formatRfc3339(Math.trunc(this.now() * 1000)), route: 'key', projectId: this.projectId, taskRunId };
172
+ const prompt = usage ? count(usage.prompt_tokens) : null;
173
+ const completion = usage ? count(usage.completion_tokens) : null;
174
+ if (prompt === null || completion === null) {
175
+ const unmeasured = makeUsageReceipt('openrouter', served.slice(0, 128), { inputTokens: 0, outputTokens: 0, tokensMeasured: false, ...common });
176
+ this.rememberUnmeasured(unmeasured, worst);
177
+ return unmeasured;
178
+ }
179
+ const details = isObject(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {};
180
+ const cached = count(details.cached_tokens) ?? 0;
181
+ const written = count(details.cache_write_tokens) ?? 0;
182
+ let cost = typeof usage.cost === 'number' ? price(usage.cost) : null;
183
+ let basis = 'provider';
184
+ if (cost === null) {
185
+ const p = this.priced(served) ?? this.priced(asked);
186
+ if (p) { cost = prompt * p.prompt + completion * p.completion; basis = 'price-list'; }
187
+ }
188
+ return makeUsageReceipt('openrouter', served.slice(0, 128), {
189
+ inputTokens: Math.max(0, prompt - cached - written), outputTokens: completion, cacheReadTokens: cached,
190
+ cacheWriteTokens: written, costUsd: cost, costBasis: cost === null ? undefined : basis, ...common,
191
+ });
192
+ }
193
+
194
+ // A receipt for an attempt that may have been billed with no usage to read: no tokens, no cost.
195
+ unmeasured(asked, taskRunId, worst) {
196
+ const receipt = makeUsageReceipt('openrouter', asked.slice(0, 128), { inputTokens: 0, outputTokens: 0, tokensMeasured: false,
197
+ at: time.formatRfc3339(Math.trunc(this.now() * 1000)), route: 'key', projectId: this.projectId, taskRunId });
198
+ this.rememberUnmeasured(receipt, worst);
199
+ return receipt;
200
+ }
201
+
202
+ rememberUnmeasured(receipt, worst) {
203
+ const key = `${receipt.at} ${receipt.model}`;
204
+ this.unmeasuredWorst.set(key, (this.unmeasuredWorst.get(key) ?? 0) + worst);
205
+ }
206
+
207
+ key() {
208
+ const key = this.env[this.keyEnv] ?? '';
209
+ if (!key || /[\r\n\0 ]/.test(key)) throw new OpenRouterError(`no usable key in the environment variable ${this.keyEnv}`, { kind: 'config' });
210
+ return key;
211
+ }
212
+
213
+ headers(key) {
214
+ const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
215
+ if (this.attribution !== null) {
216
+ Object.assign(headers, attributionHeaders(this.attribution, { url: this.baseUrl, originated: true, headers: {} }).headers);
217
+ }
218
+ return headers;
219
+ }
220
+
221
+ async chat(messages, { model, fallback, maxTokens, taskRunId, ...params } = {}) {
222
+ if (!Array.isArray(messages) || messages.length === 0) throw new OpenRouterError('messages is a non-empty list', { kind: 'config' });
223
+ if (!textOnly(messages)) {
224
+ throw new OpenRouterError('only text content is sent: an image, audio or file part has a cost the ceiling cannot bound', { kind: 'config' });
225
+ }
226
+ if (!vendor(model) || !vendor(fallback) || vendor(model) === vendor(fallback)) {
227
+ throw new OpenRouterError(`the fallback is a model of another vendor than ${model}`, { kind: 'config' });
228
+ }
229
+ if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 200_000) {
230
+ throw new OpenRouterError('maxTokens is an integer from 1 to 200000', { kind: 'config' });
231
+ }
232
+ checkAttribution(this.projectId, taskRunId); // before anything is sent: a billed call keeps its receipt
233
+ const clash = Object.keys(params).filter((k) => RESERVED_PARAMS.includes(k));
234
+ if (clash.length) throw new OpenRouterError(`the client sets ${clash.sort().join(', ')} itself`, { kind: 'config' });
235
+ const unbounded = Object.keys(params).filter((k) => UNBOUNDED_PARAMS.includes(k));
236
+ if (unbounded.length) {
237
+ throw new OpenRouterError(`${unbounded.sort().join(', ')} change what a call is billed for; the ceiling cannot bound them`, { kind: 'config' });
238
+ }
239
+ try {
240
+ promptTokensBound({ ...params, messages });
241
+ } catch {
242
+ throw new OpenRouterError('the request holds a number JSON cannot write (NaN or Infinity)', { kind: 'config' });
243
+ }
244
+ const headers = this.headers(this.key());
245
+ const url = `${this.baseUrl}/chat/completions`;
246
+ const end = this.clock() + this.deadlineS;
247
+ const attempts = [];
248
+ for (const current of [model, fallback]) {
249
+ const body = { model: current, messages, max_tokens: maxTokens, usage: { include: true }, ...params };
250
+ for (let tried = 0; tried <= this.retries; tried += 1) {
251
+ const left = end - this.clock();
252
+ if (left <= 0) throw new OpenRouterError(`the deadline passed after ${attempts.length} attempts`, { kind: 'failed', attempts });
253
+ let held;
254
+ try {
255
+ held = this.reserve(current, messages, maxTokens, params);
256
+ } catch (err) {
257
+ err.attempts = [...attempts];
258
+ throw err;
259
+ }
260
+ let result;
261
+ try {
262
+ result = await this.attempt(url, headers, body, left, current, taskRunId, held);
263
+ } finally {
264
+ this.release(held);
265
+ }
266
+ attempts.push({ model: current, status: result.status, outcome: result.outcome });
267
+ if (result.answer) return { ...result.answer, attempts };
268
+ if (Object.values(STOP_AT_ONCE).includes(result.outcome)) {
269
+ throw new OpenRouterError(`OpenRouter refused the key or the request (${result.status})`, { kind: result.outcome, attempts });
270
+ }
271
+ if (!['retry', 'error'].includes(result.outcome) || tried === this.retries) break;
272
+ const wait = result.wait ?? http.backoffS(tried, { baseS: 0.5, maxS: 8, rand: this.rand() });
273
+ if (this.clock() + wait >= end) break;
274
+ await this.sleep(wait);
275
+ }
276
+ }
277
+ throw new OpenRouterError(`no answer from ${model} or ${fallback}`, { kind: 'failed', attempts });
278
+ }
279
+
280
+ async attempt(url, headers, body, left, asked, taskRunId, worst) {
281
+ let response;
282
+ try {
283
+ response = await http.request('POST', url, { headers, body, deadlineS: left, retries: 0, transport: this.transport, clock: this.clock });
284
+ } catch (err) {
285
+ if (!(err instanceof http.HttpError)) throw err;
286
+ if (['timeout', 'connection'].includes(err.kind) || (err.kind === 'too-large' && err.status === 200)) {
287
+ // may have been billed (a cut connection looks like a refused one from here): counted, never as $0
288
+ this.ledger.record(this.unmeasured(asked, taskRunId, worst));
289
+ }
290
+ return { outcome: ['connection', 'timeout'].includes(err.kind) ? 'retry' : 'refused', status: err.status };
291
+ }
292
+ const { status } = response;
293
+ if (STOP_AT_ONCE[status]) return { outcome: STOP_AT_ONCE[status], status };
294
+ if (status !== 200) {
295
+ if (http.RETRY_STATUSES.includes(status)) return { outcome: 'retry', status, wait: http.retryAfterS(response.headers['retry-after'] ?? null) };
296
+ return { outcome: 'refused', status };
297
+ }
298
+ let parsed = null;
299
+ try { parsed = response.json(); } catch { /* read as no answer below */ }
300
+ if (!isObject(parsed)) {
301
+ this.ledger.record(this.unmeasured(asked, taskRunId, worst));
302
+ return { outcome: 'bad-answer', status };
303
+ }
304
+ let receipt;
305
+ try {
306
+ receipt = this.receipt(asked, parsed, taskRunId, worst);
307
+ } catch {
308
+ this.ledger.record(this.unmeasured(asked, taskRunId, worst)); // a billed answer it cannot read still counts
309
+ return { outcome: 'bad-answer', status };
310
+ }
311
+ this.ledger.record(receipt);
312
+ const choice = Array.isArray(parsed.choices) && isObject(parsed.choices[0]) ? parsed.choices[0] : {};
313
+ const message = isObject(choice.message) ? choice.message : {};
314
+ const finish = choice.finish_reason;
315
+ const content = typeof message.content === 'string' ? message.content : '';
316
+ const tools = Array.isArray(message.tool_calls) ? message.tool_calls : [];
317
+ if (finish === 'error' || isObject(parsed.error)) return { outcome: 'error', status };
318
+ if (finish === 'length') return { outcome: 'length', status };
319
+ if (finish === 'content_filter') return { outcome: 'filtered', status };
320
+ if (!ANSWERED.includes(finish) || !(content.trim() || tools.length)) return { outcome: 'empty', status };
321
+ return { outcome: 'answered', status, answer: { content, message, model: receipt.model, finishReason: finish, receipt } };
322
+ }
323
+ }
324
+
325
+ // Calls, priced spend and unpriced calls over receipts; spentUsd is null when every call is unpriced.
326
+ export function totals(receipts) {
327
+ let calls = 0;
328
+ let priced = 0;
329
+ let spent = 0;
330
+ for (const r of receipts) {
331
+ calls += 1;
332
+ if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) { priced += 1; spent += r.costUsd; }
333
+ }
334
+ return { calls, spentUsd: priced ? spent : null, unpricedCalls: calls - priced };
335
+ }
@@ -31,9 +31,15 @@ const SECRET_FILE = /^(\.env(\..+)?|.*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|d
31
31
  const SECRET_FILE_OK = /^\.env\.(example|sample|template)$/;
32
32
  const B64URL = /^[A-Za-z0-9_-]+$/;
33
33
  const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
34
- const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
35
- const isString = (v) => typeof v === 'string';
36
- const objects = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
34
+ /**
35
+ * How deep a document read from disk may nest. Python's recursion limit and Node's stack give out at different depths,
36
+ * so both readers refuse past this one, well below either (FAA-78, finding 43); no package document comes near it.
37
+ */
38
+ export const MAX_NESTING = 256;
39
+ /** Strict UTF-8: invalid bytes are a refusal, never replaced, so the bytes read are the bytes signed (finding 43). */
40
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
41
+ import { isObject, objects, jsonEqual } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
42
+ import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
37
43
 
38
44
  /** A refusal a person can act on; the message names what to change. */
39
45
  export class PackageError extends Error {}
@@ -49,7 +55,15 @@ export const sha256Of = (data) => `sha256:${crypto.createHash('sha256').update(d
49
55
  /** SHA-256 over the UTF-8 RFC 8785 form of the package without digest and signatures (DEC-0050). */
50
56
  export function packageDigest(pkg) {
51
57
  const { digest: _d, signatures: _s, ...covered } = pkg;
52
- return sha256Of(Buffer.from(jcs(covered), 'utf8'));
58
+ let text;
59
+ try {
60
+ text = jcs(covered);
61
+ } catch (error) {
62
+ // Nested past the stack: no form this kit can write, as Python's RecursionError is none.
63
+ if (error instanceof RangeError) throw new TypeError('the package nests too deeply to have an RFC 8785 form');
64
+ throw error;
65
+ }
66
+ return sha256Of(Buffer.from(text, 'utf8'));
53
67
  }
54
68
 
55
69
  /** The bytes a package signature covers (DEC-0051); the role is inside, so no signature stands for another role. */
@@ -116,18 +130,11 @@ export function readSigningKey(file, kid) {
116
130
  let schemaCache;
117
131
  const schema = () => (schemaCache ??= JSON.parse(fs.readFileSync(SCHEMA_FILE, 'utf8')));
118
132
 
119
- function jsonEqual(a, b) {
120
- if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
121
- if (isObject(a) || isObject(b)) {
122
- if (!isObject(a) || !isObject(b)) return false;
123
- const ka = Object.keys(a);
124
- return ka.length === Object.keys(b).length && ka.every((k) => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]));
125
- }
126
- return a === b;
127
- }
128
133
 
129
134
  const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number') : typeof v);
130
- const DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-](\d{2}):(\d{2}))$/;
135
+ const DATE_TIME = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
136
+ const MONTH_DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
137
+ const leap = (year) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
131
138
 
132
139
  function formatOk(format, value) {
133
140
  if (!isString(value)) return true;
@@ -135,8 +142,9 @@ function formatOk(format, value) {
135
142
  const m = DATE_TIME.exec(value);
136
143
  if (!m) return false;
137
144
  const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
138
- const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();
139
- if (mo < 1 || mo > 12 || d < 1 || d > days || h > 23 || mi > 59 || s > 59) return false;
145
+ // The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29 holds).
146
+ // Counted here, not with Date.UTC, which reads years 0 to 99 as 1900 to 1999.
147
+ if (mo < 1 || mo > 12 || d < 1 || d > (mo === 2 && leap(y) ? 29 : MONTH_DAYS[mo]) || h > 23 || mi > 59 || s > 59) return false;
140
148
  return m[8] === 'Z' || m[8] === 'z' || (Number(m[9]) < 24 && Number(m[10]) < 60);
141
149
  }
142
150
  if (format === 'uri') return /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/.test(value);
@@ -299,6 +307,50 @@ function mcpRoles(value) {
299
307
  return findings;
300
308
  }
301
309
 
310
+ // --- the path rule (FAA-78, finding 40; decision D5) -------------------------------------------------------------------
311
+
312
+ /** C0 and C1 controls, DEL, and the two Unicode line terminators JavaScript's `.` stops at. */
313
+ const control = (code) => code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029;
314
+
315
+ /**
316
+ * Why `p` is no packaged path, or null. The schema's pattern says the same in one expression, but JavaScript's `.`
317
+ * stops at U+2028 and U+2029, so here and in the contract's own ajv its look-aheads never see a `..`, a backslash or a
318
+ * `//` after one (`a\u2028/../../etc/passwd` passes there). This rule reads the path character by character in both
319
+ * kits, and refuses C1 controls, DEL and the line terminators besides. A non-string is the schema's to refuse.
320
+ */
321
+ export function pathProblem(p) {
322
+ if (!isString(p)) return null;
323
+ const chars = [...p];
324
+ if (chars.length < 1 || chars.length > 256) return 'a path is 1 to 256 characters';
325
+ if (chars.some((ch) => control(ch.codePointAt(0)))) return 'a path carries no control character and no line or paragraph separator';
326
+ if (p[0] === '/' || p[0] === '~') return 'a path is relative to the package folder: it never starts with / or ~';
327
+ if (p.includes('\\') || p.includes(':')) return 'a path is written with forward slashes and no colon';
328
+ if (p.includes('//') || p.endsWith('/')) return 'a path names a file: no empty segment and no trailing slash';
329
+ if (p.split('/').some((segment) => segment === '.' || segment === '..')) return 'a path never steps through . or ..';
330
+ return null;
331
+ }
332
+
333
+ /** Every member the schema types as a path, by its JSON pointer. */
334
+ function pathFields(pkg) {
335
+ const fields = [['/provider', pkg.provider]];
336
+ if (isObject(pkg.evals)) fields.push(['/evals/suite', pkg.evals.suite]);
337
+ for (const name of ['state', 'dependencies', 'files']) {
338
+ (Array.isArray(pkg[name]) ? pkg[name] : []).forEach((entry, index) => { if (isObject(entry)) fields.push([`/${name}/${index}/path`, entry.path]); });
339
+ }
340
+ return fields.filter(([, value]) => value !== undefined);
341
+ }
342
+
343
+ /** The path rule over every path the package names: `{instancePath, message}` per path refused. */
344
+ export function pathProblems(pkg) {
345
+ if (!isObject(pkg)) return [];
346
+ const out = [];
347
+ for (const [instancePath, value] of pathFields(pkg)) {
348
+ const message = pathProblem(value);
349
+ if (message !== null) out.push({ instancePath, message });
350
+ }
351
+ return out;
352
+ }
353
+
302
354
  /** FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order. */
303
355
  export const packageFindings = (pkg) => [...whole(pkg), ...noSecret(pkg), ...agentRefRule(pkg), ...mcpRoles(pkg)];
304
356
 
@@ -307,7 +359,7 @@ export const packageFindings = (pkg) => [...whole(pkg), ...noSecret(pkg), ...age
307
359
  const parsed = (bytes) => {
308
360
  if (bytes === undefined) return undefined;
309
361
  try {
310
- const value = JSON.parse(Buffer.from(bytes).toString('utf8'));
362
+ const value = JSON.parse(strictUtf8.decode(bytes)); // invalid UTF-8 is no manifest, in both kits
311
363
  return isObject(value) ? value : undefined;
312
364
  } catch {
313
365
  return undefined;
@@ -399,10 +451,37 @@ export function consumerPlacement(pkg, configured) {
399
451
  const litter = (name) => (JUNK.test(name) ? 'litter' : SECRET_FILE.test(name) && !SECRET_FILE_OK.test(name) ? 'a credential file' : null);
400
452
  const utf16Order = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // JavaScript compares strings by UTF-16 code units
401
453
 
454
+ /** Opening a packaged file: never through a link (where the platform can say so), never waiting on a pipe (finding 42). */
455
+ const FILE_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
456
+ /** What open() answers for a link it was told not to follow: ELOOP on Linux and macOS, EMLINK on FreeBSD. */
457
+ const LINK_CODES = new Set(['ELOOP', 'EMLINK']);
458
+
459
+ const unreadable = (where, error) => new PackageError(`Cannot read ${where || '.'}: ${error.code ?? error.message}.`);
460
+
461
+ /** The file's bytes, or the reason it is not read: a link, or not a regular file. Throws the system's error. */
462
+ function readRegular(file) {
463
+ let fd;
464
+ try {
465
+ fd = fs.openSync(file, FILE_FLAGS);
466
+ } catch (error) {
467
+ if (fs.constants.O_NOFOLLOW !== undefined && LINK_CODES.has(error.code)) return { why: 'a symlink' };
468
+ throw error;
469
+ }
470
+ try {
471
+ if (!fs.fstatSync(fd).isFile()) return { why: 'not a regular file' };
472
+ return { bytes: fs.readFileSync(fd) };
473
+ } finally {
474
+ fs.closeSync(fd);
475
+ }
476
+ }
477
+
402
478
  /**
403
479
  * Every regular file under `folder` by its forward-slash path. Refuses a symlink, a special file, version-control or
404
- * cache litter, and a file that is a credential by its name — each named, so the person can remove it or pack a clean
405
- * copy.
480
+ * cache litter, and a file that is a credential by its name, each named, so the person can remove it or pack a clean
481
+ * copy. A file or folder it cannot read is a refusal naming it (`Cannot read <path>: EACCES.`), never a crash (FAA-78,
482
+ * finding 41); a file is opened without following a link and read only when it is a regular file (finding 42). Node
483
+ * has no openat, so a folder swapped for a link mid-walk is checked by lstat before and after it is listed; the Python
484
+ * twin holds each folder open instead.
406
485
  */
407
486
  export function readFolder(folder) {
408
487
  let stat;
@@ -411,12 +490,31 @@ export function readFolder(folder) {
411
490
  const files = new Map();
412
491
  const refused = [];
413
492
  const walk = (dir, rel) => {
414
- for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => utf16Order(a.name, b.name))) {
493
+ let entries;
494
+ let before;
495
+ try {
496
+ before = rel ? fs.lstatSync(dir) : null;
497
+ entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => utf16Order(a.name, b.name));
498
+ } catch (error) {
499
+ throw unreadable(rel, error);
500
+ }
501
+ if (before && !before.isDirectory()) { refused.push(`${rel} (a symlink)`); return; }
502
+ for (const entry of entries) {
415
503
  const where = rel ? `${rel}/${entry.name}` : entry.name;
416
- const why = entry.isSymbolicLink() ? 'a symlink' : litter(entry.name) ?? (entry.isDirectory() || entry.isFile() ? null : 'not a regular file');
417
- if (why) { refused.push(`${where} (${why})`); continue; }
418
- if (entry.isDirectory()) walk(path.join(dir, entry.name), where);
419
- else files.set(where, fs.readFileSync(path.join(dir, entry.name)));
504
+ let why = entry.isSymbolicLink() ? 'a symlink' : litter(entry.name) ?? (entry.isDirectory() || entry.isFile() ? null : 'not a regular file');
505
+ if (!why && entry.isDirectory()) { walk(path.join(dir, entry.name), where); continue; }
506
+ if (!why) {
507
+ let read;
508
+ try { read = readRegular(path.join(dir, entry.name)); } catch (error) { throw unreadable(where, error); }
509
+ if (read.why === undefined) { files.set(where, read.bytes); continue; }
510
+ why = read.why;
511
+ }
512
+ refused.push(`${where} (${why})`);
513
+ }
514
+ if (before) {
515
+ let after;
516
+ try { after = fs.lstatSync(dir); } catch (error) { throw unreadable(rel, error); }
517
+ if (!after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev) refused.push(`${rel} (a symlink)`);
420
518
  }
421
519
  };
422
520
  walk(folder, '');
@@ -424,8 +522,15 @@ export function readFolder(folder) {
424
522
  return files;
425
523
  }
426
524
 
427
- /** JSON.parse that refuses a member named twice in one object, as the Python kit's reader does. */
428
- function parseStrict(text, what) {
525
+ /**
526
+ * A JSON object from strict UTF-8 bytes (a string is taken as decoded already): no member named twice, nesting at most
527
+ * MAX_NESTING deep, as the Python kit's reader. Every refusal is a PackageError.
528
+ */
529
+ function parseStrict(input, what) {
530
+ let text = input;
531
+ if (typeof input !== 'string') {
532
+ try { text = strictUtf8.decode(input); } catch (error) { throw new PackageError(`${what} is not JSON: ${error.message}`); }
533
+ }
429
534
  let value;
430
535
  try { value = JSON.parse(text); } catch (error) { throw new PackageError(`${what} is not JSON: ${error.message}`); }
431
536
  const stack = [];
@@ -433,6 +538,7 @@ function parseStrict(text, what) {
433
538
  const skipString = () => { i += 1; let s = ''; while (text[i] !== '"') { if (text[i] === '\\') { s += text.slice(i, i + 2); i += 2; } else { s += text[i]; i += 1; } } i += 1; return JSON.parse(`"${s}"`); };
434
539
  while (i < text.length) {
435
540
  const c = text[i];
541
+ if ((c === '{' || c === '[') && stack.length >= MAX_NESTING) throw new PackageError(`${what} nests deeper than ${MAX_NESTING} levels.`);
436
542
  if (c === '{') { stack.push({ keys: new Set(), expectKey: true }); i += 1; } else if (c === '[') { stack.push(null); i += 1; } else if (c === '}' || c === ']') { stack.pop(); i += 1; } else if (c === ',') { const top = stack.at(-1); if (top) top.expectKey = true; i += 1; } else if (c === '"') {
437
543
  const top = stack.at(-1);
438
544
  const s = skipString();
@@ -461,20 +567,28 @@ export function compose(draft, files) {
461
567
  throw new PackageError(`agentRef is derived, never chosen: it is ${agentRefOf(draft.publisher, draft.id)} for this publisher and id (FAC-SEM-066). Remove agentRef or fix publisher/id.`);
462
568
  }
463
569
  const pkg = {};
570
+ // A member is defined, never assigned: `pkg.__proto__ = v` would set the prototype and drop the member, where the
571
+ // Python twin keeps it for the schema to refuse (finding 43).
572
+ const put = (key, value) => Object.defineProperty(pkg, key, { value, enumerable: true, writable: true, configurable: true });
464
573
  for (const [key, value] of Object.entries(draft)) {
465
574
  if (key === 'files' || key === 'digest' || key === 'signatures' || (agent && key === 'agentRef')) continue;
466
- pkg[key] = value;
467
- if (agent && key === 'publisher') pkg.agentRef = agentRefOf(draft.publisher, draft.id);
575
+ put(key, value);
576
+ if (agent && key === 'publisher') put('agentRef', agentRefOf(draft.publisher, draft.id));
468
577
  }
469
578
  if (Array.isArray(draft.signatures) && draft.signatures.length) notes.push(`dropped ${draft.signatures.length} signature(s): a repacked package is signed again`);
470
579
  pkg.files = [...files.keys()].filter((p) => p !== PACKAGE_FILE).sort(utf16Order).map((p) => ({ path: p, sha256: sha256Of(files.get(p)) }));
471
- pkg.digest = packageDigest(pkg);
580
+ try {
581
+ pkg.digest = packageDigest(pkg);
582
+ } catch (error) {
583
+ throw new PackageError(`fabric.package.json has no RFC 8785 form, so it has no digest: ${error.message}`);
584
+ }
472
585
  return { pkg, notes };
473
586
  }
474
587
 
475
588
  function allProblems(pkg, files) {
476
589
  return [
477
590
  ...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
591
+ ...pathProblems(pkg).map((p) => ({ ...p, rule: 'path' })),
478
592
  ...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
479
593
  ...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: p.problem })),
480
594
  ];
@@ -493,7 +607,7 @@ export function pack(folder, { apply = false } = {}) {
493
607
  if (!files.has(PACKAGE_FILE)) {
494
608
  throw new PackageError(`Write ${PACKAGE_FILE} in the folder first: schema, kind, id, version, publisher, licence and, for an agent, resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest.`);
495
609
  }
496
- const draft = parseStrict(files.get(PACKAGE_FILE).toString('utf8'), PACKAGE_FILE);
610
+ const draft = parseStrict(files.get(PACKAGE_FILE), PACKAGE_FILE);
497
611
  const { pkg, notes } = compose(draft, files);
498
612
  const problems = allProblems(pkg, files);
499
613
  const suite = isObject(pkg.evals) ? pkg.evals.suite : undefined;
@@ -511,7 +625,7 @@ export function pack(folder, { apply = false } = {}) {
511
625
  function packed(folder) {
512
626
  const files = readFolder(folder);
513
627
  if (!files.has(PACKAGE_FILE)) throw new PackageError(`${folder} has no ${PACKAGE_FILE}: pack it first.`);
514
- return { pkg: parseStrict(files.get(PACKAGE_FILE).toString('utf8'), PACKAGE_FILE), files };
628
+ return { pkg: parseStrict(files.get(PACKAGE_FILE), PACKAGE_FILE), files };
515
629
  }
516
630
 
517
631
  /**
@@ -575,7 +689,7 @@ export function main(argv, print = (line) => process.stdout.write(`${line}\n`))
575
689
  answer = sign(folder, flags.key, { role: flags.role ?? 'publisher', kid: flags.kid, apply: Boolean(flags.apply) });
576
690
  } else {
577
691
  let trustText;
578
- try { trustText = fs.readFileSync(flags.trust, 'utf8'); } catch (error) { throw new PackageError(`Cannot read ${flags.trust}: ${error.code ?? error.message}.`); }
692
+ try { trustText = fs.readFileSync(flags.trust); } catch (error) { throw new PackageError(`Cannot read ${flags.trust}: ${error.code ?? error.message}.`); }
579
693
  answer = verify(folder, parseStrict(trustText, flags.trust), { countersigned: Boolean(flags.countersigned) });
580
694
  }
581
695
  } catch (error) {