@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +9 -3
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -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
- deviceKeys.forEach((key, k) => {
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)");
@@ -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
- const decision = this.repeat(caller, idempotencyKey, now ?? nowIso(), { limitHolds, approvalGranted });
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
- // The answer to a key this caller sent before: {decision: 'new'|'same'|'run', job}.
394
- repeat(caller, key, now, options = {}) {
395
- let id;
396
- try { id = JSON.parse(fs.readFileSync(this.keyFile(caller, key), 'utf8')).job; } catch (e) {
397
- if (e.code === 'ENOENT') return { decision: 'new', job: null };
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
- return { decision: repeatedKeyDecision(job, now, options), job };
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
- if (resetsAt !== undefined) {
452
- const wait = utcTime(resetsAt) - Date.now();
453
- if (wait > 0) { const record = this.load(jobId); record.job.pollIntervalMs = Math.trunc(Math.min(wait, 3600000)); this.save(record); }
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?}.
@@ -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+, no dependency.
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);
@@ -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, pathToFileURL } from 'node:url';
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
- return sha256Of(Buffer.from(jcs(covered), 'utf8'));
58
+ let text;
59
+ try {
60
+ text = jcs(covered);
61
+ } catch (error) {
62
+ // Nested past the stack: no form this kit can write, as Python's RecursionError is none.
63
+ if (error instanceof RangeError) throw new TypeError('the package nests too deeply to have an RFC 8785 form');
64
+ throw error;
65
+ }
66
+ return sha256Of(Buffer.from(text, 'utf8'));
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 = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-](\d{2}):(\d{2}))$/;
135
+ const DATE_TIME = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
136
+ const MONTH_DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
137
+ const leap = (year) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
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
- const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();
129
- if (mo < 1 || mo > 12 || d < 1 || d > days || h > 23 || mi > 59 || s > 59) return false;
145
+ // The proleptic calendar the contract's ajv-formats checks: year 0000 exists and is a leap year (0000-02-29 holds).
146
+ // Counted here, not with Date.UTC, which reads years 0 to 99 as 1900 to 1999.
147
+ if (mo < 1 || mo > 12 || d < 1 || d > (mo === 2 && leap(y) ? 29 : MONTH_DAYS[mo]) || h > 23 || mi > 59 || s > 59) return false;
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(Buffer.from(bytes).toString('utf8'));
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 — each named, so the person can remove it or pack a clean
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
- for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => utf16Order(a.name, b.name))) {
493
+ let entries;
494
+ let before;
495
+ try {
496
+ before = rel ? fs.lstatSync(dir) : null;
497
+ entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => utf16Order(a.name, b.name));
498
+ } catch (error) {
499
+ throw unreadable(rel, error);
500
+ }
501
+ if (before && !before.isDirectory()) { refused.push(`${rel} (a symlink)`); return; }
502
+ for (const entry of entries) {
405
503
  const where = rel ? `${rel}/${entry.name}` : entry.name;
406
- const why = entry.isSymbolicLink() ? 'a symlink' : litter(entry.name) ?? (entry.isDirectory() || entry.isFile() ? null : 'not a regular file');
407
- if (why) { refused.push(`${where} (${why})`); continue; }
408
- if (entry.isDirectory()) walk(path.join(dir, entry.name), where);
409
- else files.set(where, fs.readFileSync(path.join(dir, entry.name)));
504
+ let why = entry.isSymbolicLink() ? 'a symlink' : litter(entry.name) ?? (entry.isDirectory() || entry.isFile() ? null : 'not a regular file');
505
+ if (!why && entry.isDirectory()) { walk(path.join(dir, entry.name), where); continue; }
506
+ if (!why) {
507
+ let read;
508
+ try { read = readRegular(path.join(dir, entry.name)); } catch (error) { throw unreadable(where, error); }
509
+ if (read.why === undefined) { files.set(where, read.bytes); continue; }
510
+ why = read.why;
511
+ }
512
+ refused.push(`${where} (${why})`);
513
+ }
514
+ if (before) {
515
+ let after;
516
+ try { after = fs.lstatSync(dir); } catch (error) { throw unreadable(rel, error); }
517
+ if (!after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev) refused.push(`${rel} (a symlink)`);
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
- /** JSON.parse that refuses a member named twice in one object, as the Python kit's reader does. */
418
- function parseStrict(text, what) {
525
+ /**
526
+ * A JSON object from strict UTF-8 bytes (a string is taken as decoded already): no member named twice, nesting at most
527
+ * MAX_NESTING deep, as the Python kit's reader. Every refusal is a PackageError.
528
+ */
529
+ function parseStrict(input, what) {
530
+ let text = input;
531
+ if (typeof input !== 'string') {
532
+ try { text = strictUtf8.decode(input); } catch (error) { throw new PackageError(`${what} is not JSON: ${error.message}`); }
533
+ }
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
- pkg[key] = value;
457
- if (agent && key === 'publisher') pkg.agentRef = agentRefOf(draft.publisher, draft.id);
575
+ put(key, value);
576
+ if (agent && key === 'publisher') put('agentRef', agentRefOf(draft.publisher, draft.id));
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
- pkg.digest = packageDigest(pkg);
580
+ try {
581
+ pkg.digest = packageDigest(pkg);
582
+ } catch (error) {
583
+ throw new PackageError(`fabric.package.json has no RFC 8785 form, so it has no digest: ${error.message}`);
584
+ }
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).toString('utf8'), 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).toString('utf8'), PACKAGE_FILE), files };
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, 'utf8'); } catch (error) { throw new PackageError(`Cannot read ${flags.trust}: ${error.code ?? error.message}.`); }
692
+ try { trustText = fs.readFileSync(flags.trust); } catch (error) { throw new PackageError(`Cannot read ${flags.trust}: ${error.code ?? error.message}.`); }
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
- if (import.meta.url === pathToFileURL(process.argv[1] || '').href) process.exitCode = main(process.argv.slice(2));
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));
@@ -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
- const systemdQuote = (value) => `"${plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
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.request
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."
@@ -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))