@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1
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 +160 -0
- package/README.md +9 -3
- 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 +28 -30
- 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 +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- 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 +2 -2
- 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 +3 -2
- 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/scripts/check_dashboard_layout.mjs +68 -24
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- 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 +4 -2
- 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 +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- 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 +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- 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 +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- 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_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
- 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/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 +36 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -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-estate.mjs
CHANGED
|
@@ -238,6 +238,7 @@ export function verifyEstateRecord(record, trust) {
|
|
|
238
238
|
* undefined when the record places no such agent there in that context.
|
|
239
239
|
*/
|
|
240
240
|
export function placementTrust(record, machineId, agentRef, context = PERSONAL_CONTEXT) {
|
|
241
|
+
if (!isObject(record)) return undefined; // a record that is no object places nothing, as in the Python twin
|
|
241
242
|
const machine = list(record.machines).find((entry) => entry.id === machineId);
|
|
242
243
|
const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef && contextOf(entry) === context);
|
|
243
244
|
if (!placement) return undefined;
|
|
@@ -378,7 +379,8 @@ function keys(value, flag) {
|
|
|
378
379
|
if (deviceKeys.filter((key) => key.use === 'sig').length !== 1) flag(`${at}/deviceKeys`, 'a machine has exactly one signing device key, <id>#sig');
|
|
379
380
|
if (deviceKeys.filter((key) => key.use === 'kem').length > 1) flag(`${at}/deviceKeys`, 'a machine has at most one key-agreement device key, <id>#kem');
|
|
380
381
|
const seen = new Set();
|
|
381
|
-
|
|
382
|
+
// The index is the key's position in the list as written, non-objects counted, as the contract and the Python twin name it.
|
|
383
|
+
indexed(machine.deviceKeys).forEach(([key, k]) => {
|
|
382
384
|
if (key.kid !== `${show(machine.id)}#${show(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
|
|
383
385
|
// A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
|
|
384
386
|
if (deviceKeyFingerprint(key) === undefined) flag(`${at}/deviceKeys/${k}/publicKey`, "a device key's octets are written in canonical padded base64, the one form a key reference can name (DEC-0084)");
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -347,6 +347,15 @@ export function urlRequest(message, url) {
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
// --- C3.2 jobs: the same file format as fabric_interop.JobStore ------------------------------
|
|
350
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
351
|
+
|
|
352
|
+
/** What a key is kept beside (FAA-77): sha256 of the RFC 8785 form of [capability, input]; an input with none is refused. */
|
|
353
|
+
export function intentDigest(capability, input) {
|
|
354
|
+
let form;
|
|
355
|
+
try { form = jcs([capability, input]); } catch { throw new InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785)."); }
|
|
356
|
+
return `sha256:${crypto.createHash('sha256').update(form, 'utf8').digest('hex')}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
350
359
|
export class JobStore {
|
|
351
360
|
constructor(dir) { this.dir = dir; }
|
|
352
361
|
|
|
@@ -370,36 +379,63 @@ export class JobStore {
|
|
|
370
379
|
|
|
371
380
|
// With idempotencyKey the key is kept per caller (the authenticated caller, DEC-0087): a key that already names a job
|
|
372
381
|
// starts nothing and throws, unless repeat() answers 'run'; then the key names the new job.
|
|
382
|
+
// The key is kept beside the intent's digest (FAA-77): a key that names a job of another intent is idempotency-conflict.
|
|
383
|
+
// A key file a kit before 0.41.0 wrote carries no intent: it is answered with its job as 0.40.0 answered it (see repeat).
|
|
373
384
|
create(capability, input, { traceparent, pollIntervalMs, caller = null, idempotencyKey, now, limitHolds, approvalGranted = false } = {}) {
|
|
385
|
+
let intent = null;
|
|
374
386
|
if (idempotencyKey !== undefined) {
|
|
375
|
-
|
|
387
|
+
intent = intentDigest(capability, input);
|
|
388
|
+
const decision = this.repeat(caller, idempotencyKey, now ?? nowIso(), { limitHolds, approvalGranted, capability, input });
|
|
389
|
+
if (decision.decision === 'conflict') throw new InteropError(`idempotency-conflict: key ${idempotencyKey} names a job of another intent; send the same capability and input, or a new key.`);
|
|
376
390
|
if (decision.decision === 'same') throw new InteropError(`Key ${idempotencyKey} already names job ${decision.job.id}; answer with that job (JobStore.repeat).`);
|
|
377
391
|
}
|
|
378
392
|
const id = `job_${crypto.randomBytes(12).toString('hex')}`;
|
|
379
393
|
const job = { id, status: 'working' };
|
|
380
394
|
if (pollIntervalMs) job.pollIntervalMs = Math.trunc(pollIntervalMs);
|
|
381
395
|
this.save({ job, private: { capability, input, traceparent: parseTraceparent(traceparent) ? traceparent : null } });
|
|
382
|
-
if (idempotencyKey !== undefined) atomicWrite(this.keyFile(caller, idempotencyKey), `${JSON.stringify({ job: id })}\n`, 0o600);
|
|
396
|
+
if (idempotencyKey !== undefined) atomicWrite(this.keyFile(caller, idempotencyKey), `${JSON.stringify({ job: id, intent })}\n`, 0o600);
|
|
383
397
|
return { job: { id, status: 'working' } };
|
|
384
398
|
}
|
|
385
399
|
|
|
386
400
|
keyFile(caller, key) {
|
|
387
401
|
if (typeof key !== 'string' || key.length === 0) throw new InteropError('An idempotency key is a non-empty string.');
|
|
402
|
+
if (LONE_SURROGATE.test(key) || (typeof caller === 'string' && LONE_SURROGATE.test(caller))) throw new InteropError('An idempotency key and its caller are Unicode text; a lone surrogate is refused.');
|
|
388
403
|
const dir = path.join(this.dir, 'keys');
|
|
389
404
|
ensurePrivateDir(dir);
|
|
390
405
|
return path.join(dir, `${crypto.createHash('sha256').update(JSON.stringify([caller ?? null, key]), 'utf8').digest('hex')}.json`);
|
|
391
406
|
}
|
|
392
407
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (e
|
|
398
|
-
throw new InteropError(`Key ${key} is unreadable on disk.`);
|
|
408
|
+
keyRecord(caller, key) {
|
|
409
|
+
let record;
|
|
410
|
+
try { record = JSON.parse(fs.readFileSync(this.keyFile(caller, key), 'utf8')); } catch (e) {
|
|
411
|
+
if (e.code === 'ENOENT') return null;
|
|
412
|
+
if (e instanceof InteropError) throw e;
|
|
399
413
|
}
|
|
414
|
+
if (record === null || typeof record !== 'object' || Array.isArray(record)) throw new InteropError(`Key ${key} is unreadable on disk.`);
|
|
415
|
+
return record;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The answer to a key this caller sent before: {decision: 'new'|'same'|'run'|'conflict', job}. 'same': answer with
|
|
419
|
+
// that job; 'run': the intent runs again, once; 'conflict': given options.capability (and options.input), the key was
|
|
420
|
+
// kept beside another intent (idempotency-conflict, FAA-77). A key file a kit before 0.41.0 wrote holds only
|
|
421
|
+
// {"job": id}, no intent: it never answers 'conflict', and 'run' only for the intent its job stored; otherwise 'same'.
|
|
422
|
+
repeat(caller, key, now, options = {}) {
|
|
423
|
+
const record = this.keyRecord(caller, key);
|
|
424
|
+
if (record === null) return { decision: 'new', job: null };
|
|
425
|
+
const id = record.job;
|
|
426
|
+
if (typeof id !== 'string') throw new InteropError(`Key ${key} is unreadable on disk.`);
|
|
400
427
|
let job;
|
|
401
428
|
try { job = this.get(id); } catch (e) { if (e instanceof UnknownJob) return { decision: 'new', job: null }; throw e; }
|
|
402
|
-
|
|
429
|
+
let decision = repeatedKeyDecision(job, now, options);
|
|
430
|
+
if (options.capability === undefined || options.capability === null) return { decision, job };
|
|
431
|
+
const intent = intentDigest(options.capability, options.input ?? null);
|
|
432
|
+
if (Object.hasOwn(record, 'intent')) return { decision: record.intent !== intent ? 'conflict' : decision, job };
|
|
433
|
+
// A 0.40.0 key runs only the intent its job stored (an input with no JSON form is refused, as the Python kit refuses it).
|
|
434
|
+
if (decision === 'run') {
|
|
435
|
+
const stored = this.load(id).private ?? {};
|
|
436
|
+
if (intentDigest(stored.capability ?? null, stored.input ?? null) !== intent) decision = 'same';
|
|
437
|
+
}
|
|
438
|
+
return { decision, job };
|
|
403
439
|
}
|
|
404
440
|
|
|
405
441
|
get(jobId) { return { ...this.load(jobId).job }; }
|
|
@@ -448,11 +484,9 @@ export class JobStore {
|
|
|
448
484
|
// A job a limit paused and its provider will resume: working, the pause grammar, pollIntervalMs reaching the reset.
|
|
449
485
|
pause(jobId, { resetsAt, text } = {}) {
|
|
450
486
|
const statusMessage = pauseMessage({ resetsAt, text });
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
return this.transition(jobId, 'working', { statusMessage });
|
|
487
|
+
const wait = resetsAt === undefined ? 0 : utcTime(resetsAt) - Date.now();
|
|
488
|
+
// One write, after the terminal check: a terminal job does not change, pollIntervalMs included (finding 11).
|
|
489
|
+
return this.transition(jobId, 'working', { statusMessage, pollIntervalMs: wait > 0 ? Math.trunc(Math.min(wait, 3600000)) : undefined });
|
|
456
490
|
}
|
|
457
491
|
|
|
458
492
|
// A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock and the supervisor marker.
|
|
2
|
-
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_lock.py. Node 20
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_lock.py. Node 20+ (22+ for the Linux lock), no dependency.
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import net from 'node:net';
|
|
@@ -52,6 +52,12 @@ export class InstanceLock {
|
|
|
52
52
|
// with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
|
|
53
53
|
const name = lockName(this.path);
|
|
54
54
|
if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
|
|
55
|
+
const names = process.platform === 'linux' ? await abstractNames() : 'exact';
|
|
56
|
+
if (names === 'none' || names === 'padded') {
|
|
57
|
+
// Node 18 and 20 cannot bind the name the Python kit binds: 18 and early 20 make every name 108 NUL bytes (one lock
|
|
58
|
+
// for every service on the machine), later 20 pads it with NUL bytes (a name no Python copy or probe sees).
|
|
59
|
+
throw new ServiceError(`Node ${process.version} cannot hold the instance lock's abstract socket name on Linux; run the service on Node 22 or later.`);
|
|
60
|
+
}
|
|
55
61
|
this.server = net.createServer();
|
|
56
62
|
await new Promise((resolve, reject) => {
|
|
57
63
|
// Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
|
|
@@ -72,6 +78,33 @@ export class InstanceLock {
|
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
let abstractNamesSeen = null;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How this Node binds an abstract unix socket name on Linux, learned once by binding a random one and reading
|
|
85
|
+
* /proc/net/unix: 'exact' (Node 21 and later: the name the Python kit binds and fabric_lock.lock_name_held reads),
|
|
86
|
+
* 'padded' (Node 20.x: the name followed by NUL bytes to the whole sun_path) or 'none' (Node 18 and early 20: every
|
|
87
|
+
* name becomes 108 NUL bytes). 'unknown' when the table cannot be read.
|
|
88
|
+
*/
|
|
89
|
+
export async function abstractNames() {
|
|
90
|
+
if (abstractNamesSeen !== null) return abstractNamesSeen;
|
|
91
|
+
const probe = `fabric-probe-${crypto.randomBytes(8).toString('hex')}`;
|
|
92
|
+
const server = net.createServer();
|
|
93
|
+
try {
|
|
94
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(`\0${probe}`, resolve); });
|
|
95
|
+
const rows = fs.readFileSync('/proc/net/unix', 'utf8').split('\n').map((row) => row.trimEnd().split(' ').pop());
|
|
96
|
+
if (rows.includes(`@${probe}`)) abstractNamesSeen = 'exact';
|
|
97
|
+
else if (rows.includes(`@${probe}`.padEnd(108, '@'))) abstractNamesSeen = 'padded';
|
|
98
|
+
else abstractNamesSeen = 'none';
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// A random name already in use means every name collapses to one: another such Node holds it.
|
|
101
|
+
abstractNamesSeen = error?.code === 'EADDRINUSE' ? 'none' : 'unknown';
|
|
102
|
+
} finally {
|
|
103
|
+
server.close();
|
|
104
|
+
}
|
|
105
|
+
return abstractNamesSeen;
|
|
106
|
+
}
|
|
107
|
+
|
|
75
108
|
/** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
|
|
76
109
|
export function lockName(lockPath, platform = process.platform) {
|
|
77
110
|
const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
-
import { fileURLToPath
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { isSmallOrderKey, jcs } from './fabric-delegation.mjs';
|
|
20
20
|
import { secretArgument, secretValue } from './fabric-secrets.mjs';
|
|
21
21
|
|
|
@@ -31,6 +31,13 @@ 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
|
+
* 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 });
|
|
34
41
|
import { isObject, objects, jsonEqual } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
35
42
|
import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
36
43
|
|
|
@@ -48,7 +55,15 @@ export const sha256Of = (data) => `sha256:${crypto.createHash('sha256').update(d
|
|
|
48
55
|
/** SHA-256 over the UTF-8 RFC 8785 form of the package without digest and signatures (DEC-0050). */
|
|
49
56
|
export function packageDigest(pkg) {
|
|
50
57
|
const { digest: _d, signatures: _s, ...covered } = pkg;
|
|
51
|
-
|
|
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'));
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
/** The bytes a package signature covers (DEC-0051); the role is inside, so no signature stands for another role. */
|
|
@@ -117,7 +132,9 @@ const schema = () => (schemaCache ??= JSON.parse(fs.readFileSync(SCHEMA_FILE, 'u
|
|
|
117
132
|
|
|
118
133
|
|
|
119
134
|
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number') : typeof v);
|
|
120
|
-
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);
|
|
121
138
|
|
|
122
139
|
function formatOk(format, value) {
|
|
123
140
|
if (!isString(value)) return true;
|
|
@@ -125,8 +142,9 @@ function formatOk(format, value) {
|
|
|
125
142
|
const m = DATE_TIME.exec(value);
|
|
126
143
|
if (!m) return false;
|
|
127
144
|
const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
|
|
128
|
-
|
|
129
|
-
|
|
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;
|
|
130
148
|
return m[8] === 'Z' || m[8] === 'z' || (Number(m[9]) < 24 && Number(m[10]) < 60);
|
|
131
149
|
}
|
|
132
150
|
if (format === 'uri') return /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/.test(value);
|
|
@@ -289,6 +307,50 @@ function mcpRoles(value) {
|
|
|
289
307
|
return findings;
|
|
290
308
|
}
|
|
291
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
|
+
|
|
292
354
|
/** FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order. */
|
|
293
355
|
export const packageFindings = (pkg) => [...whole(pkg), ...noSecret(pkg), ...agentRefRule(pkg), ...mcpRoles(pkg)];
|
|
294
356
|
|
|
@@ -297,7 +359,7 @@ export const packageFindings = (pkg) => [...whole(pkg), ...noSecret(pkg), ...age
|
|
|
297
359
|
const parsed = (bytes) => {
|
|
298
360
|
if (bytes === undefined) return undefined;
|
|
299
361
|
try {
|
|
300
|
-
const value = JSON.parse(
|
|
362
|
+
const value = JSON.parse(strictUtf8.decode(bytes)); // invalid UTF-8 is no manifest, in both kits
|
|
301
363
|
return isObject(value) ? value : undefined;
|
|
302
364
|
} catch {
|
|
303
365
|
return undefined;
|
|
@@ -389,10 +451,37 @@ export function consumerPlacement(pkg, configured) {
|
|
|
389
451
|
const litter = (name) => (JUNK.test(name) ? 'litter' : SECRET_FILE.test(name) && !SECRET_FILE_OK.test(name) ? 'a credential file' : null);
|
|
390
452
|
const utf16Order = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // JavaScript compares strings by UTF-16 code units
|
|
391
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
|
+
|
|
392
478
|
/**
|
|
393
479
|
* Every regular file under `folder` by its forward-slash path. Refuses a symlink, a special file, version-control or
|
|
394
|
-
* cache litter, and a file that is a credential by its name
|
|
395
|
-
* 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.
|
|
396
485
|
*/
|
|
397
486
|
export function readFolder(folder) {
|
|
398
487
|
let stat;
|
|
@@ -401,12 +490,31 @@ export function readFolder(folder) {
|
|
|
401
490
|
const files = new Map();
|
|
402
491
|
const refused = [];
|
|
403
492
|
const walk = (dir, rel) => {
|
|
404
|
-
|
|
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) {
|
|
405
503
|
const where = rel ? `${rel}/${entry.name}` : entry.name;
|
|
406
|
-
|
|
407
|
-
if (why) {
|
|
408
|
-
if (
|
|
409
|
-
|
|
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)`);
|
|
410
518
|
}
|
|
411
519
|
};
|
|
412
520
|
walk(folder, '');
|
|
@@ -414,8 +522,15 @@ export function readFolder(folder) {
|
|
|
414
522
|
return files;
|
|
415
523
|
}
|
|
416
524
|
|
|
417
|
-
/**
|
|
418
|
-
|
|
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
|
+
}
|
|
419
534
|
let value;
|
|
420
535
|
try { value = JSON.parse(text); } catch (error) { throw new PackageError(`${what} is not JSON: ${error.message}`); }
|
|
421
536
|
const stack = [];
|
|
@@ -423,6 +538,7 @@ function parseStrict(text, what) {
|
|
|
423
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}"`); };
|
|
424
539
|
while (i < text.length) {
|
|
425
540
|
const c = text[i];
|
|
541
|
+
if ((c === '{' || c === '[') && stack.length >= MAX_NESTING) throw new PackageError(`${what} nests deeper than ${MAX_NESTING} levels.`);
|
|
426
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 === '"') {
|
|
427
543
|
const top = stack.at(-1);
|
|
428
544
|
const s = skipString();
|
|
@@ -451,20 +567,28 @@ export function compose(draft, files) {
|
|
|
451
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.`);
|
|
452
568
|
}
|
|
453
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 });
|
|
454
573
|
for (const [key, value] of Object.entries(draft)) {
|
|
455
574
|
if (key === 'files' || key === 'digest' || key === 'signatures' || (agent && key === 'agentRef')) continue;
|
|
456
|
-
|
|
457
|
-
if (agent && key === 'publisher')
|
|
575
|
+
put(key, value);
|
|
576
|
+
if (agent && key === 'publisher') put('agentRef', agentRefOf(draft.publisher, draft.id));
|
|
458
577
|
}
|
|
459
578
|
if (Array.isArray(draft.signatures) && draft.signatures.length) notes.push(`dropped ${draft.signatures.length} signature(s): a repacked package is signed again`);
|
|
460
579
|
pkg.files = [...files.keys()].filter((p) => p !== PACKAGE_FILE).sort(utf16Order).map((p) => ({ path: p, sha256: sha256Of(files.get(p)) }));
|
|
461
|
-
|
|
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
|
+
}
|
|
462
585
|
return { pkg, notes };
|
|
463
586
|
}
|
|
464
587
|
|
|
465
588
|
function allProblems(pkg, files) {
|
|
466
589
|
return [
|
|
467
590
|
...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
|
|
591
|
+
...pathProblems(pkg).map((p) => ({ ...p, rule: 'path' })),
|
|
468
592
|
...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
|
|
469
593
|
...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: p.problem })),
|
|
470
594
|
];
|
|
@@ -483,7 +607,7 @@ export function pack(folder, { apply = false } = {}) {
|
|
|
483
607
|
if (!files.has(PACKAGE_FILE)) {
|
|
484
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.`);
|
|
485
609
|
}
|
|
486
|
-
const draft = parseStrict(files.get(PACKAGE_FILE)
|
|
610
|
+
const draft = parseStrict(files.get(PACKAGE_FILE), PACKAGE_FILE);
|
|
487
611
|
const { pkg, notes } = compose(draft, files);
|
|
488
612
|
const problems = allProblems(pkg, files);
|
|
489
613
|
const suite = isObject(pkg.evals) ? pkg.evals.suite : undefined;
|
|
@@ -501,7 +625,7 @@ export function pack(folder, { apply = false } = {}) {
|
|
|
501
625
|
function packed(folder) {
|
|
502
626
|
const files = readFolder(folder);
|
|
503
627
|
if (!files.has(PACKAGE_FILE)) throw new PackageError(`${folder} has no ${PACKAGE_FILE}: pack it first.`);
|
|
504
|
-
return { pkg: parseStrict(files.get(PACKAGE_FILE)
|
|
628
|
+
return { pkg: parseStrict(files.get(PACKAGE_FILE), PACKAGE_FILE), files };
|
|
505
629
|
}
|
|
506
630
|
|
|
507
631
|
/**
|
|
@@ -565,7 +689,7 @@ export function main(argv, print = (line) => process.stdout.write(`${line}\n`))
|
|
|
565
689
|
answer = sign(folder, flags.key, { role: flags.role ?? 'publisher', kid: flags.kid, apply: Boolean(flags.apply) });
|
|
566
690
|
} else {
|
|
567
691
|
let trustText;
|
|
568
|
-
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}.`); }
|
|
569
693
|
answer = verify(folder, parseStrict(trustText, flags.trust), { countersigned: Boolean(flags.countersigned) });
|
|
570
694
|
}
|
|
571
695
|
} catch (error) {
|
|
@@ -577,4 +701,15 @@ export function main(argv, print = (line) => process.stdout.write(`${line}\n`))
|
|
|
577
701
|
return answer.ok ? 0 : 1;
|
|
578
702
|
}
|
|
579
703
|
|
|
580
|
-
|
|
704
|
+
// Whether node was started on this file. Real paths, not URLs: the launcher installs the plugin under a symlinked
|
|
705
|
+
// folder (~/.passioncode/current), and comparing import.meta.url (resolved) with argv[1] (the link) never ran main
|
|
706
|
+
// (board thread b42e16b7).
|
|
707
|
+
function startedDirectly() {
|
|
708
|
+
try {
|
|
709
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
710
|
+
} catch {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (startedDirectly()) process.exitCode = main(process.argv.slice(2));
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -67,7 +67,11 @@ function checkRun(result, what) {
|
|
|
67
67
|
if (result?.status !== 0) throw new ServiceError(`${what} failed: ${String(result?.stderr || result?.stdout || result?.error?.message || '').trim().slice(0, 200)}`);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// ExecStart= expands $VAR, so its $ is doubled; Environment= expands none, so `dollar` false leaves it alone (finding 17).
|
|
71
|
+
const systemdQuote = (value, dollar = true) => {
|
|
72
|
+
const escaped = plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
|
|
73
|
+
return `"${dollar ? escaped.replace(/\$/g, '$$$$') : escaped}"`;
|
|
74
|
+
};
|
|
71
75
|
|
|
72
76
|
/**
|
|
73
77
|
* A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
|
|
@@ -87,10 +91,10 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
|
|
|
87
91
|
if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
|
|
88
92
|
return [
|
|
89
93
|
'[Unit]', `Description=${plain(description ?? unit, 'The description').replace(/%/g, '%%')}`, '',
|
|
90
|
-
'[Service]', 'Type=simple', `ExecStart=${argv.map(systemdQuote).join(' ')}`,
|
|
94
|
+
'[Service]', 'Type=simple', `ExecStart=${argv.map((a) => systemdQuote(a)).join(' ')}`,
|
|
91
95
|
// Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
92
96
|
`WorkingDirectory=${cwd.replace(/%/g, '%%')}`,
|
|
93
|
-
...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}
|
|
97
|
+
...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`, false)}`),
|
|
94
98
|
'Restart=always', 'RestartSec=10', `TimeoutStopSec=${Math.trunc(stopTimeout)}`, 'KillMode=mixed', '',
|
|
95
99
|
'[Install]', 'WantedBy=default.target', '',
|
|
96
100
|
].join('\n');
|
|
@@ -13,7 +13,7 @@ import secrets
|
|
|
13
13
|
import stat
|
|
14
14
|
import threading
|
|
15
15
|
import time
|
|
16
|
-
import urllib.
|
|
16
|
+
import urllib.parse
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
19
19
|
|
|
@@ -51,7 +51,7 @@ OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60
|
|
|
51
51
|
OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}
|
|
54
|
+
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}\Z")
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def ensure_token(path: Path) -> str:
|
|
@@ -330,18 +330,24 @@ def expired_session_cookie_header(remote: bool = False) -> str:
|
|
|
330
330
|
return (remote_session_cookie_header if remote else session_cookie_header)("", 0)
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
def _default_port_dropped(scheme: str, value: str) -> str:
|
|
334
|
+
"""A host or an origin as a WHATWG URL writes it (finding 15): the scheme's default port (https 443, http 80) left out."""
|
|
335
|
+
default = {"https": ":443", "http": ":80"}.get(scheme.lower())
|
|
336
|
+
return value[:-len(default)] if default and value.endswith(default) else value
|
|
337
|
+
|
|
338
|
+
|
|
333
339
|
def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
|
|
334
340
|
sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
|
|
335
341
|
"""DEC-0019: the request guard of an online service; returns the refusal sentence or None.
|
|
336
342
|
``forwarded_proto`` is the platform-set scheme where TLS ends before the process; pass None
|
|
337
|
-
when the process terminates TLS itself."""
|
|
343
|
+
when the process terminates TLS itself. A default port, written or not, names the same host."""
|
|
338
344
|
own = urllib.parse.urlsplit(origin)
|
|
339
|
-
own_host = own.netloc.lower()
|
|
340
|
-
if str(host or "").lower() != own_host:
|
|
345
|
+
own_host = _default_port_dropped(own.scheme, own.netloc.lower())
|
|
346
|
+
if _default_port_dropped(own.scheme, str(host or "").lower()) != own_host:
|
|
341
347
|
return "Host %s is not this service." % host
|
|
342
348
|
if forwarded_proto is not None and str(forwarded_proto).split(",")[0].strip() != "https":
|
|
343
349
|
return "This service answers over https only."
|
|
344
|
-
if request_origin is not None and request_origin != "%s://%s" % (own.scheme, own_host):
|
|
350
|
+
if request_origin is not None and _default_port_dropped(own.scheme, str(request_origin)) != "%s://%s" % (own.scheme, own_host):
|
|
345
351
|
return "Origin %s is not this service." % request_origin
|
|
346
352
|
if sec_fetch_site == "cross-site":
|
|
347
353
|
return "Cross-site requests are refused."
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py
CHANGED
|
@@ -46,22 +46,22 @@ _STALE_TEMP_SECONDS = 60 * 60
|
|
|
46
46
|
_SAFE_INTEGER = 2 ** 53 - 1
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
_TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}
|
|
49
|
+
_TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}\Z")
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
_SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)
|
|
52
|
+
_SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)\Z")
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
_COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}
|
|
55
|
+
_COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}\Z")
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
_SECRET_COLUMN = re.compile(r"^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s
|
|
58
|
+
_SECRET_COLUMN = re.compile(r"^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?\Z")
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
_REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}
|
|
61
|
+
_REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}\Z")
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
_SHA256 = re.compile(r"^[0-9a-f]{64}
|
|
64
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}\Z")
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import datetime as _dt
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import threading
|
|
8
9
|
from pathlib import Path
|
|
10
|
+
from typing import Dict
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
PROTOCOL = "fabric-service/0.1"
|
|
@@ -26,10 +28,10 @@ SUPERVISOR_ENV = "FABRIC_SERVICE_SUPERVISOR"
|
|
|
26
28
|
DRAIN_SECONDS = 8.0
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}
|
|
31
|
+
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z")
|
|
30
32
|
|
|
31
33
|
|
|
32
|
-
_INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}
|
|
34
|
+
_INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}\Z")
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
# DEC-0032: a local path is POSIX (`/…`, `~/…`) or Windows (`C:\…`, `C:/…`, `~\…`); a network
|
|
@@ -38,10 +40,10 @@ _INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
|
38
40
|
_LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service
|
|
43
|
+
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service\Z")
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}
|
|
46
|
+
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}\Z")
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
WINDOWS = os.name == "nt"
|
|
@@ -70,6 +72,17 @@ def expand(path: str) -> Path:
|
|
|
70
72
|
return p
|
|
71
73
|
|
|
72
74
|
|
|
75
|
+
_STORE_LOCKS: Dict[str, threading.RLock] = {}
|
|
76
|
+
_STORE_LOCKS_GUARD = threading.Lock()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def store_lock(path: Path) -> threading.RLock:
|
|
80
|
+
"""One lock per store path in this process (R3): a ThreadingHTTPServer runs a store's load-modify-save callers side
|
|
81
|
+
by side (the job store, the event log), and two store instances on one path share it."""
|
|
82
|
+
with _STORE_LOCKS_GUARD:
|
|
83
|
+
return _STORE_LOCKS.setdefault(str(Path(path).resolve()), threading.RLock())
|
|
84
|
+
|
|
85
|
+
|
|
73
86
|
def _require_id(service_id: str) -> None:
|
|
74
87
|
if not (isinstance(service_id, str) and _ID.fullmatch(service_id)):
|
|
75
88
|
raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
|