@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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs
ADDED
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 = /^(
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
if (why) {
|
|
418
|
-
if (
|
|
419
|
-
|
|
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
|
-
/**
|
|
428
|
-
|
|
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
|
-
|
|
467
|
-
if (agent && key === 'publisher')
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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) {
|