@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +5 -3
  3. package/SECURITY.md +4 -2
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
  47. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  48. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  49. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  50. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  51. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  52. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  56. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -1,5 +1,15 @@
1
1
  // One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock and the supervisor marker.
2
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
+ //
4
+ // A lock that moves under its holder (FAA-303). Linux and Windows hold a name keyed by the lock's path (an abstract
5
+ // unix socket, a named pipe), so a folder swapped under a running copy cannot let a second copy in. macOS has no such
6
+ // name: O_EXLOCK (and the Python twin's flock) lock an inode, so after a data import swaps the data folder a second
7
+ // copy locks the new file and starts. There the holder watches its lock instead (InstanceLock#watch, started by
8
+ // holdSingleInstance): every LOCK_WATCH_SECONDS it compares the inode it locked with the file at the path, and once
9
+ // the path names another file it stops with 75 and one sentence naming the path, before writing anything more into a
10
+ // folder that is no longer the one it read. Two copies then overlap for at most one interval. A path-keyed name on
11
+ // macOS was weighed and left: it needs a file in a folder outside the data folder that the system never cleans and
12
+ // every copy can write, which Node alone cannot promise.
3
13
  import crypto from 'node:crypto';
4
14
  import fs from 'node:fs';
5
15
  import net from 'node:net';
@@ -15,6 +25,12 @@ export const LOCK_BACKOFF_FIRST_SECONDS = 0.5;
15
25
 
16
26
  export const LOCK_BACKOFF_MAX_SECONDS = 30;
17
27
 
28
+ /**
29
+ * How often a holder that has no path-keyed name (macOS) compares the inode it locked with the file at the lock's path
30
+ * (FAA-303): one stat and one fstat; two copies overlap for at most this long after a folder swap.
31
+ */
32
+ export const LOCK_WATCH_SECONDS = 2;
33
+
18
34
  const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
19
35
 
20
36
  export class AlreadyRunning extends ServiceError {
@@ -34,6 +50,7 @@ export class InstanceLock {
34
50
  this.path = path.join(dataDir, 'service.lock');
35
51
  this.fd = null;
36
52
  this.server = null;
53
+ this.timer = null;
37
54
  }
38
55
 
39
56
  async acquire() {
@@ -124,14 +141,65 @@ export class InstanceLock {
124
141
  return true;
125
142
  }
126
143
 
144
+ /**
145
+ * Re-check, every `intervalSeconds` on an unref'd timer, that the file this copy locked is still the file at the path
146
+ * (FAA-303); the first time the path names another file (a swapped folder's lock, or the one a second copy made),
147
+ * call `onLost(lock)` once and stop watching. The default stops the process with 75 and one sentence
148
+ * (stopLostHolder). A path that is gone or cannot be read now is read as nothing there yet and tried again next time:
149
+ * a second copy has to make a file there to start, and the watch then sees that file. `release` ends the watch. Only
150
+ * a lock held as a file is watched (macOS): elsewhere the descriptor is null and the path-keyed name keeps a second
151
+ * copy out, so this is a no-op, as for a lock not held.
152
+ */
153
+ watch({ onLost, intervalSeconds = LOCK_WATCH_SECONDS } = {}) {
154
+ if (this.fd === null || this.timer !== null) return this;
155
+ const fd = this.fd;
156
+ const react = onLost ?? stopLostHolder;
157
+ this.timer = setInterval(() => {
158
+ let here;
159
+ let held;
160
+ try {
161
+ here = fs.statSync(this.path);
162
+ held = fs.fstatSync(fd);
163
+ } catch {
164
+ return;
165
+ }
166
+ if (here.dev === held.dev && here.ino === held.ino) return;
167
+ clearInterval(this.timer);
168
+ this.timer = null;
169
+ react(this);
170
+ }, intervalSeconds * 1000);
171
+ this.timer.unref();
172
+ return this;
173
+ }
174
+
127
175
  release() {
176
+ if (this.timer !== null) clearInterval(this.timer);
128
177
  if (this.fd !== null) fs.closeSync(this.fd);
129
178
  if (this.server) this.server.close();
179
+ this.timer = null;
130
180
  this.fd = null;
131
181
  this.server = null;
132
182
  }
133
183
  }
134
184
 
185
+ /** The one sentence a holder whose lock moved under it stops with (FAA-303). */
186
+ export function lostLockMessage(lockPath) {
187
+ return `${lockPath} is no longer the file this copy locked (the data folder moved or was swapped?): stopping, so a copy started on the folder now at that path runs alone.`;
188
+ }
189
+
190
+ /**
191
+ * InstanceLock#watch's default: write lostLockMessage and end the process with 75 at once. Not a graceful stop: a
192
+ * shutdown would write this copy's state into the folder now at the path, which is not the one it read; the kit's
193
+ * journals make an abrupt stop recoverable (LC-03).
194
+ */
195
+ export function stopLostHolder(lock) {
196
+ try {
197
+ fs.writeSync(2, `${lostLockMessage(lock.path)}\n`);
198
+ } finally {
199
+ process.exit(EXIT_ALREADY_RUNNING);
200
+ }
201
+ }
202
+
135
203
  let abstractNamesSeen = null;
136
204
 
137
205
  /**
@@ -253,10 +321,12 @@ const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 100
253
321
  * supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
254
322
  * forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
255
323
  * LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
256
- * the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
324
+ * the wait runs out. `waitSeconds` overrides the choice (0 = never wait). Where the lock is held as a file (macOS)
325
+ * the held lock is watched (InstanceLock#watch, FAA-303): a folder swapped under the running copy stops it with 75;
326
+ * `onLost` replaces that reaction.
257
327
  */
258
328
  export async function holdSingleInstance(dataDir, {
259
- waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
329
+ waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv, onLost,
260
330
  } = {}) {
261
331
  // DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
262
332
  const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
@@ -270,7 +340,7 @@ export async function holdSingleInstance(dataDir, {
270
340
  let announced = false;
271
341
  for (;;) {
272
342
  try {
273
- return await new InstanceLock(dataDir).acquire();
343
+ return (await new InstanceLock(dataDir).acquire()).watch({ onLost });
274
344
  } catch (error) {
275
345
  if (!(error instanceof AlreadyRunning)) throw error;
276
346
  const remaining = deadline - clock();
@@ -40,12 +40,14 @@
40
40
  // privilege. The parsers are pure functions over captured text (test/fixtures/peer-owner/).
41
41
 
42
42
  import { execFile } from 'node:child_process';
43
+ import { realpathSync } from 'node:fs';
43
44
  import { open } from 'node:fs/promises';
44
45
  import http from 'node:http';
45
46
  import net from 'node:net';
46
47
  import os from 'node:os';
48
+ import { fileURLToPath } from 'node:url';
47
49
 
48
- export const PEER_OWNER_VERSION = '1.2.0+kit.5'; // the Python twin's, Observatory's 1.2.0 with the kit's differences
50
+ export const PEER_OWNER_VERSION = '1.2.0+kit.6'; // the Python twin's, Observatory's 1.2.0 with the kit's differences
49
51
  export const THIS_ACCOUNT = 'this-account';
50
52
  export const ANOTHER_ACCOUNT = 'another-account';
51
53
  export const SYSTEM = 'system';
@@ -62,6 +64,8 @@ export const LSOF = '/usr/sbin/lsof';
62
64
  // The reason a socket table hides other processes' sockets from this one (FAA-267): macOS 27.2 gives an ad-hoc-signed
63
65
  // runtime an empty netstat.
64
66
  export const TABLE_RESTRICTED = 'table-restricted';
67
+ // A macOS table that lists sockets but not the reader's own connection (DEC-0155 RT-1, FAA-312).
68
+ export const NO_ROW_RESTRICTED = `${TABLE_RESTRICTED}: no row for this connection`;
65
69
  export const SERVER_NOT_OWN = 'server-not-own';
66
70
  export const SERVER_UNVERIFIED = 'server-unverified';
67
71
  export const SERVER_NOT_THIS_WORKSPACE = 'server-not-this-workspace';
@@ -290,10 +294,10 @@ export function runProgram(file, args, timeout = TIMEOUT_MS) {
290
294
  });
291
295
  }
292
296
 
293
- export function method() {
294
- if (process.platform === 'darwin') return 'netstat';
295
- if (process.platform === 'linux') return 'proc';
296
- if (process.platform === 'win32') return 'netstat-windows';
297
+ export function method(platform = process.platform) {
298
+ if (platform === 'darwin') return 'netstat';
299
+ if (platform === 'linux') return 'proc';
300
+ if (platform === 'win32') return 'netstat-windows';
297
301
  return 'none';
298
302
  }
299
303
 
@@ -319,9 +323,18 @@ async function readProc(name) {
319
323
  return Buffer.concat(chunks);
320
324
  }
321
325
 
322
- export async function readTable({ run = runProgram } = {}) {
323
- const how = method();
324
- if (how === 'netstat') return { rows: parseNetstat(await run(NETSTAT, ['-anv', '-p', 'tcp'])), how };
326
+ export async function readTable({ run = runProgram, platform = process.platform } = {}) {
327
+ const how = method(platform);
328
+ if (how === 'netstat') {
329
+ let text;
330
+ try {
331
+ text = await run(NETSTAT, ['-anv', '-p', 'tcp']);
332
+ } catch (err) {
333
+ // A netstat that fails, exits non-zero or times out is a restricted table (DEC-0155 RT-1, FAA-312).
334
+ throw new TableUnreadable(`${TABLE_RESTRICTED}: ${err instanceof TableUnreadable ? err.message : `netstat: ${err?.name ?? 'error'}`}`);
335
+ }
336
+ return { rows: parseNetstat(text), how };
337
+ }
325
338
  if (how === 'proc') {
326
339
  const rows = [];
327
340
  for (const [name, family] of [['/proc/net/tcp', 4], ['/proc/net/tcp6', 6]]) {
@@ -387,6 +400,21 @@ export async function resolve(row, how, { run = runProgram, ownUid: mine, ownSid
387
400
 
388
401
  // -- the questions ----------------------------------------------------------------------------------------------------
389
402
 
403
+ // DEC-0155 RT-1 (FAA-312): a macOS table holding no row of another uid (sysctl, the Python twin's, which carries uids)
404
+ // or of another process (netstat, which carries pids) is restricted, and a missing row proves nothing. Never on Linux
405
+ // or Windows, which carry the owner on each row.
406
+ export function restricted(rows, how, { ownUid: mine, ownPid } = {}) {
407
+ if (how === 'sysctl') {
408
+ const own = mine ?? ownUid();
409
+ return !rows.some((r) => r.uid !== null && r.uid !== undefined && r.uid !== own);
410
+ }
411
+ if (how === 'netstat') {
412
+ const own = ownPid ?? process.pid;
413
+ return !rows.some((r) => r.pid !== null && r.pid !== undefined && r.pid !== own);
414
+ }
415
+ return false;
416
+ }
417
+
390
418
  // The live connected rows with exactly this local and remote address: one, or the caller cannot tell.
391
419
  // A row printed twice is one socket: Linux resumes a /proc read that sockets came and went under by position, and can
392
420
  // print the same row again (0.74.0 seam reading 6); rows equal in every field count once.
@@ -414,7 +442,8 @@ async function ownerOf(local, remote, opts = {}, server = false) {
414
442
  // A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
415
443
  if (table.how !== 'proc' || found.length === 1) break;
416
444
  }
417
- if (found.length === 0) return owner(UNKNOWN, { method: table.how, reason: 'no-row' });
445
+ // A macOS table without the reader's own connection is restricted (DEC-0155 RT-1, FAA-312).
446
+ if (found.length === 0) return owner(UNKNOWN, { method: table.how, reason: ['sysctl', 'netstat'].includes(table.how) ? NO_ROW_RESTRICTED : 'no-row' });
418
447
  if (found.length > 1) return owner(UNKNOWN, { method: table.how, reason: 'several-rows' });
419
448
  if (table.how === 'proc' && server && found[0].uid === null) {
420
449
  // Linux: a connection not yet accept()ed is printed with uid 0 and inode 0; it belongs to the listener on its local
@@ -491,23 +520,28 @@ export async function listener(port, opts = {}) {
491
520
  const at = now();
492
521
  const hit = injected ? undefined : listeners.get(port);
493
522
  if (hit && at - hit[0] < LISTENER_TTL_MS) return hit[1];
494
- let found;
495
- let how;
496
523
  let table;
497
524
  try {
498
525
  table = await (read ?? readTable)(opts);
499
- how = table.how;
500
- found = pickListener(table.rows, port);
501
526
  } catch (err) {
502
527
  const who = owner(UNKNOWN, { method: method(), reason: err instanceof TableUnreadable ? err.message : `table: ${err.name}` });
503
528
  if (!injected) listeners.set(port, [at, who]);
504
529
  return who;
505
530
  }
506
- const who = await oneOwner(found, how, opts);
531
+ const who = restricted(table.rows, table.how, opts) ? await restrictedListener(table.rows, port, table.how, opts)
532
+ : await oneOwner(pickListener(table.rows, port), table.how, opts);
507
533
  if (!injected) listeners.set(port, [at, who]);
508
534
  return who;
509
535
  }
510
536
 
537
+ // DEC-0155 RT-4 (FAA-312): under a restricted table only this account's own bind on exactly 127.0.0.1 is its own; its
538
+ // wildcard or ::1 listener, root's, and a port with no row are an owner that cannot be told.
539
+ async function restrictedListener(rows, port, how, opts) {
540
+ const who = await oneOwner(rows.filter((r) => r.listening && r.local[0] === '127.0.0.1' && r.local[1] === port), how, opts);
541
+ if (who.kind === THIS_ACCOUNT) return who;
542
+ return owner(UNKNOWN, { method: how, reason: `${TABLE_RESTRICTED}: no listener of this account on exactly 127.0.0.1:${port}` });
543
+ }
544
+
511
545
  // [code, why] when a client must not send on a connection whose server end is `who`, else null.
512
546
  export function refusal(who, port, expectPid = null) {
513
547
  if (who.kind === ANOTHER_ACCOUNT || who.kind === SYSTEM) return [SERVER_NOT_OWN, `the server on port ${port} belongs to ${who.kind}`];
@@ -518,16 +552,20 @@ export function refusal(who, port, expectPid = null) {
518
552
  return null;
519
553
  }
520
554
 
521
- // macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's, listens on 127.0.0.1:<port>
522
- // or the IPv4 wildcard (IPv4 alone: a v6-only wildcard never vouches for a 127.0.0.1 connection). `lsof -p` asks the process itself, which the restriction does not hide. Only a caller that started
523
- // `pid` itself (a launchd job it installed) may vouch with it.
524
- export async function pidListens(pid, port, { run = runProgram, platform = process.platform, ownUid: mine } = {}) {
555
+ // macOS, when the socket table is restricted (FAA-267, DEC-0155 RT-3): whether process `pid`, this account's, listens on
556
+ // exactly 127.0.0.1:<port> (never the IPv4 wildcard, which lsof prints as it prints an IPv6-only one, nor ::1; FAA-312).
557
+ // `lsof -p` asks the process itself, which the restriction does not hide. Only a caller that started `pid` itself (a
558
+ // launchd job it installed) may vouch with it, and never a set-uid one (its real and effective uid differ, DEC-0155).
559
+ export async function pidListens(pid, port, { run = runProgram, platform = process.platform, ownUid: mine, ownEuid } = {}) {
525
560
  if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
561
+ const real = mine ?? ownUid();
562
+ const effective = ownEuid ?? (typeof process.geteuid === 'function' ? process.geteuid() : real);
563
+ if (effective !== real) return false;
526
564
  try {
527
565
  const uid = parsePsUid(await run(PS, ['-o', 'uid=', '-p', String(pid)]));
528
- if (uid === null || uid !== (mine ?? ownUid())) return false;
566
+ if (uid === null || uid !== real) return false;
529
567
  const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
530
- return parseLsofListeners(out).some(([host, p]) => (host === '127.0.0.1' || host === '*') && p === port);
568
+ return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port);
531
569
  } catch {
532
570
  return false;
533
571
  }
@@ -587,3 +625,32 @@ export class CheckedAgent extends http.Agent {
587
625
  expectPid: this.expectPid, vouchPid: this.vouchPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
588
626
  }
589
627
  }
628
+
629
+ const USAGE = 'usage: fabric-peer-owner.mjs listener <port> [--json]\n';
630
+
631
+ // `listener <port> [--json]`: the kind of who listens on 127.0.0.1:<port>, which the dashboard link gate takes as
632
+ // listener_owner (FAA-315), or the whole owner as JSON; 2 on a usage error. `listener` and `write` are the tests'.
633
+ export async function main(argv, { listener: ask = listener, write = (text) => process.stdout.write(text) } = {}) {
634
+ const asJson = argv.length === 3 && argv[2] === '--json';
635
+ const port = Number(argv[1]);
636
+ if (argv.length !== 2 + Number(asJson) || argv[0] !== 'listener' || !/^[0-9]+$/.test(argv[1] ?? '') || port < 1 || port > 65535) {
637
+ process.stderr.write(USAGE);
638
+ return 2;
639
+ }
640
+ const who = await ask(port);
641
+ write(asJson ? `${JSON.stringify({ kind: who.kind, uid: who.uid, pid: who.pid, method: who.method, reason: who.reason })}\n` : `${who.kind}\n`);
642
+ return 0;
643
+ }
644
+
645
+ // Whether node was started on this file, by real paths (a symlinked install folder, as check_dashboard_layout.mjs).
646
+ function startedDirectly() {
647
+ try {
648
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
649
+ } catch {
650
+ return false;
651
+ }
652
+ }
653
+
654
+ if (startedDirectly()) {
655
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => { process.stderr.write(`${err?.message ?? err}\n`); process.exitCode = 1; });
656
+ }
@@ -38,6 +38,31 @@ export function credentialName(name) {
38
38
 
39
39
  /** A JWT or JWS anywhere in a value. */
40
40
  export const JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/;
41
+ const isUrlBase64 = (code) => (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || (code >= 0x30 && code <= 0x39)
42
+ || code === 0x2d || code === 0x5f;
43
+ const runEnd = (text, from) => { let end = from; while (end < text.length && isUrlBase64(text.charCodeAt(end))) end += 1; return end; };
44
+ /** The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's jwtSpans,
45
+ * FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one
46
+ * run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends. `first`
47
+ * stops at the first match. */
48
+ export function jwtSpans(text, first = false) {
49
+ const spans = [];
50
+ let from = 0;
51
+ for (let at = text.indexOf('eyJ', from); at >= 0; at = text.indexOf('eyJ', from)) {
52
+ const one = runEnd(text, at + 3);
53
+ from = one;
54
+ if (one - at - 3 < 2 || text[one] !== '.') continue;
55
+ const two = runEnd(text, one + 1);
56
+ if (two - one - 1 < 2 || text[two] !== '.') continue;
57
+ const three = runEnd(text, two + 1);
58
+ spans.push([at, three]);
59
+ if (first) break;
60
+ from = three;
61
+ }
62
+ return spans;
63
+ }
64
+ /** Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's hasJwt, FAA-313). */
65
+ export const hasJwt = (text) => jwtSpans(text, true).length > 0;
41
66
  /** A value that says it is a credential (`Bearer ` at its start, in any case), or a provider key's shape with its prefix
42
67
  * in its canonical case only, at the start of a value, after a character that is no ASCII letter or digit, or after an
43
68
  * escape; an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more,
@@ -48,7 +73,7 @@ const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
48
73
  export const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
49
74
 
50
75
  /** Whether a value could be a credential: a JWT anywhere, a provider key or `Bearer …`, or a long secret-shaped run. */
51
- export const secretValue = (value) => JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || longSecret(value);
76
+ export const secretValue = (value) => hasJwt(value) || SECRET_VALUE.test(value) || longSecret(value);
52
77
 
53
78
  export const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
54
79
  const PATH_LIKE = /^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*:\/\/)/i;
@@ -58,7 +83,7 @@ const pathLike = (piece) =>
58
83
 
59
84
  /** The same for a launch argument or a server's command and URL: a long run is looked for between path separators. */
60
85
  export const secretArgument = (value) =>
61
- JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
86
+ hasJwt(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
62
87
  value.split(/[\s=]/).some((piece) => (pathLike(piece) ? piece.split(/[\\/]/).some((part) => longSecret(part)) : longSecret(piece)));
63
88
 
64
89
  const decodeKeepingPlus = (raw) => { try { return decodeURIComponent(raw); } catch { return raw; } };
@@ -122,7 +147,8 @@ export function repositoryPassword(value) {
122
147
 
123
148
  // A repository's parameters, read as fabric_secrets.secret_url reads them, by hand: `new URL`
124
149
  // refuses a host it cannot parse and a special scheme written without `//` (`https:host/r?access_token=...`), where
125
- // the Python twin still reads them (0.62.0 readings 8 and 9, FAA-200 (17)).
150
+ // the Python twin's repository_credential reads them too (0.62.0 readings 8 and 9, FAA-200 (17)); its secret_url, like
151
+ // secretUrl, refuses such a URL since FAA-309.
126
152
  function repositoryParts(head, tail) {
127
153
  const text = tail.replace(/[\x00-\x20]+$/, '').replace(/[\t\n\r]/g, '');
128
154
  const hash = text.indexOf('#');
@@ -52,8 +52,11 @@ export {
52
52
  LOCK_WAIT_SUPERVISED_SECONDS,
53
53
  LOCK_BACKOFF_FIRST_SECONDS,
54
54
  LOCK_BACKOFF_MAX_SECONDS,
55
+ LOCK_WATCH_SECONDS,
55
56
  AlreadyRunning,
56
57
  InstanceLock,
58
+ lostLockMessage,
59
+ stopLostHolder,
57
60
  lockName,
58
61
  SUPERVISORS,
59
62
  SUPERVISOR_ARG,
@@ -4,10 +4,7 @@
4
4
  `degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
5
5
  from __future__ import annotations
6
6
 
7
- import bisect
8
- import ipaddress
9
7
  import re
10
- import unicodedata
11
8
  import urllib.parse
12
9
  from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
13
10
 
@@ -18,7 +15,12 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
18
15
 
19
16
  from fabric_base import ServiceError # noqa: E402
20
17
  from fabric_secrets import ( # noqa: E402
21
- JWT_SHAPE, _BEARER_INSIDE as _BEARER, _long_secret, credential_name, raw_values, secret_argument, secret_value,
18
+ _BEARER_INSIDE as _BEARER, _long_secret, credential_name, has_jwt, raw_values, secret_argument, secret_value,
19
+ )
20
+ # The WHATWG host reading and its IDNA tables (FAA-98) live in fabric_secrets since FAA-309, where secret_url reads a
21
+ # URL's host with them; the names stay here for code and tests that reached them.
22
+ from fabric_secrets import ( # noqa: E402,F401
23
+ _FORBIDDEN_DOMAIN, _FORBIDDEN_HOST, _IDNA_DISALLOWED, _IDNA_IGNORED, _domain_ok, _idna_host, _ipv4_number, _ipv6_ok,
22
24
  )
23
25
 
24
26
  CONNECTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1"
@@ -171,153 +173,6 @@ _credential_name, _secret_value, _secret_argument = credential_name, secret_valu
171
173
 
172
174
 
173
175
  _SPECIAL = ("http", "https", "ws", "wss", "ftp", "file")
174
- _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
175
- _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
176
-
177
-
178
- # FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
179
- # the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
180
- # test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
181
- # code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
182
- # classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
183
- # Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
184
- _IDNA_DISALLOWED = (
185
- (0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
186
- (0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
187
- (0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
188
- (0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
189
- (0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
190
- (0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
191
- (0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
192
- (0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
193
- (0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
194
- (0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
195
- (0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
196
- (0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
197
- (0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
198
- (0x100000, 0x10fffd),
199
- )
200
- _IDNA_IGNORED = (
201
- (0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
202
- (0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
203
- )
204
- _IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
205
- _U32 = unicodedata.ucd_3_2_0
206
- _RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
207
- _JOINERS = "\u200c\u200d"
208
-
209
-
210
- def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
211
- i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
212
- return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
213
-
214
-
215
- def _known(c: str) -> bool:
216
- return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
217
-
218
-
219
- def _joiner_refused(label: str, i: int) -> bool:
220
- """CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
221
- (`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
222
- in its label has none for certain."""
223
- return all(c.isascii() for j, c in enumerate(label) if j != i)
224
-
225
-
226
- def _bidi_refused(label: str) -> bool:
227
- """RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
228
- not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
229
- if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
230
- return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
231
- k = [_U32.bidirectional(c) for c in label]
232
- if not any(x in ("R", "AL", "AN") for x in k):
233
- return False
234
- body = list(k)
235
- while body and body[-1] == "NSM":
236
- body.pop()
237
- if not body or body[-1] not in ("R", "AL", "EN", "AN"):
238
- return True
239
- return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
240
-
241
-
242
- def _idna_host(host: str) -> Optional[str]:
243
- """None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
244
- as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
245
- if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
246
- return None
247
- kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
248
- if kept == "":
249
- return None # nothing left: an empty host
250
- if not all(_known(c) for c in kept):
251
- return host # a code point this Python's Unicode does not know: the contextual rules are not read
252
- mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
253
- for label in _IDNA_DOTS.split(mapped):
254
- first = label[:1]
255
- if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
256
- return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
257
- if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
258
- return None
259
- if _bidi_refused(label):
260
- return None
261
- return _IDNA_DOTS.sub(".", mapped)
262
-
263
-
264
- def _ipv4_number(part: str) -> Optional[int]:
265
- if part == "":
266
- return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
267
- base = 10
268
- if part[:2].lower() == "0x":
269
- part, base = part[2:], 16
270
- elif len(part) > 1 and part[0] == "0":
271
- part, base = part[1:], 8
272
- if part == "":
273
- return 0
274
- digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
275
- return int(part, base) if all(c in digits for c in part) else None
276
-
277
-
278
- def _domain_ok(host: str) -> bool:
279
- """The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
280
- if host.startswith("["):
281
- return host.endswith("]") and _ipv6_ok(host[1:-1])
282
- if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
283
- try:
284
- host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
285
- except UnicodeDecodeError:
286
- return False
287
- if any(c in _FORBIDDEN_DOMAIN for c in host):
288
- return False
289
- # A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
290
- # the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
291
- # (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
292
- if any(ord(c) > 0x7f for c in host):
293
- mapped = _idna_host(host)
294
- if mapped is None:
295
- return False
296
- if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
297
- return True
298
- host = mapped
299
- labels = host.split(".")
300
- if labels and labels[-1] == "" and len(labels) > 1:
301
- labels = labels[:-1]
302
- last = labels[-1] if labels else ""
303
- if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
304
- return host != ""
305
- numbers = [_ipv4_number(label) for label in labels]
306
- if len(numbers) > 4 or any(n is None for n in numbers):
307
- return False
308
- return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
309
-
310
-
311
- def _ipv6_ok(text: str) -> bool:
312
- if "%" in text:
313
- return False # a zone id: `ipaddress` takes one, WHATWG does not
314
- try:
315
- ipaddress.IPv6Address(text)
316
- return True
317
- except ValueError:
318
- return False
319
-
320
-
321
176
  _SURROGATE = re.compile("[\ud800-\udfff]")
322
177
  _QUERY_ENCODE = set(chr(c) for c in range(0x21)) | set('"#<>')
323
178
  _FRAGMENT_ENCODE = set(chr(c) for c in range(0x21)) | set('"<>`')
@@ -388,7 +243,7 @@ def _whatwg_parts(value: str) -> Optional[Dict[str, str]]:
388
243
  return None
389
244
  elif any(c in _FORBIDDEN_HOST for c in host):
390
245
  return None
391
- elif host == "" and ("@" in authority or port):
246
+ elif host == "" and ("@" in authority or ":" in hostport):
392
247
  return None
393
248
  return {"password": password, "query": query, "fragment": fragment}
394
249
 
@@ -424,7 +279,7 @@ def _secret_url(value: str) -> bool:
424
279
 
425
280
 
426
281
  def _credential(value: str, runs: bool) -> bool:
427
- return bool(JWT_SHAPE.search(value) or _KEY_SHAPE.search(value) or _BEARER.search(value)
282
+ return bool(has_jwt(value) or _KEY_SHAPE.search(value) or _BEARER.search(value) # has_jwt: one pass (FAA-313)
428
283
  or (runs and _long_run(value)) or any(_secret_url(url) for url in _urls_in(value)))
429
284
 
430
285
 
@@ -55,7 +55,7 @@ from fabric_usage import (
55
55
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
56
56
 
57
57
 
58
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
58
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.7"
59
59
 
60
60
 
61
61
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -55,6 +55,14 @@ LIFT_ACTION = "limit.lift"
55
55
  _MAX_STATUS_MESSAGE = 500
56
56
  _WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
57
57
  _SIZES = ("jobs", "usd", "limit")
58
+ #: One run of ECMAScript's whitespace, the class both twins squash with (FAA-316): Python's str.split() also takes
59
+ #: U+001C to U+001F and U+0085 and not U+FEFF, so the twins wrote different statusMessages.
60
+ _SPACES = re.compile("[%s]+" % fabric_secrets.JS_SPACE)
61
+
62
+
63
+ def _squash(text: str) -> str:
64
+ """Runs of ECMAScript whitespace as one space, none at either end (the Node twin's `squash`)."""
65
+ return _SPACES.sub(" ", text).strip(" ")
58
66
 
59
67
 
60
68
  def _instant(value: Any, name: str) -> str:
@@ -75,10 +83,10 @@ def waiting_message(reason: str, since: str, until: Optional[str] = None, messag
75
83
  text = "waiting %s since %s" % (reason, since)
76
84
  head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
77
85
  if message is not None:
78
- message = " ".join(str(message).split())
86
+ message = _squash(str(message))
79
87
  room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
80
88
  if message and room > 0:
81
- text += ": " + (message if len(message) <= room else message[:room - 3].rstrip() + "...")
89
+ text += ": " + (message if len(message) <= room else message[:room - 3].rstrip(" ") + "...")
82
90
  return pause_message(until, text)
83
91
 
84
92
 
@@ -124,11 +132,11 @@ def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) ->
124
132
 
125
133
 
126
134
  def _text(value: Any, name: str, high: int = 200) -> str:
127
- if not isinstance(value, str) or not value.strip() or len(value) > high:
135
+ if not isinstance(value, str) or not _squash(value) or len(value) > high:
128
136
  raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
129
137
  if fabric_secrets.secret_value(value):
130
138
  raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
131
- return " ".join(value.split())
139
+ return _squash(value)
132
140
 
133
141
 
134
142
  class HoldBook:
@@ -73,8 +73,9 @@ class HttpError(Exception):
73
73
  self.retry_after_s = retry_after_s
74
74
  self.body = bytes(body[:2048])
75
75
  self.kind = kind # http (an answer), connection, timeout, too-large, refused, foreign-user
76
- #: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, which
77
- #: never clears), the owner's kind (`another-account`, `system`) or the check's reason (`no-row`).
76
+ #: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, or on
77
+ #: macOS no row for this connection, DEC-0155 RT-1; it never clears), the owner's kind (`another-account`,
78
+ #: `system`) or the check's reason (`no-row`, from Linux and Windows only).
78
79
  self.reason = reason
79
80
 
80
81