@passioncode-ai/fabric-agent-adapter 0.80.2 → 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 (92) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +18 -5
  3. package/SECURITY.md +25 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  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 +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  79. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  86. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  90. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  91. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -38,13 +38,42 @@ 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
- /** A value that says it is a credential, or a provider key's shape. */
42
- export const SECRET_VALUE = /(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})/i;
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;
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
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
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,
69
+ * within 256 characters (the contract's SECRET_VALUE at the pin, DEC-0151). A match includes its boundary character:
70
+ * callers test, never read the matched text or its index. */
71
+ export const SECRET_VALUE = /(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)(?:sk-(?=[A-Za-z0-9_-]{0,255}?\d)(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))/;
43
72
  const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
44
73
  export const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
45
74
 
46
75
  /** Whether a value could be a credential: a JWT anywhere, a provider key or `Bearer …`, or a long secret-shaped run. */
47
- 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);
48
77
 
49
78
  export const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
50
79
  const PATH_LIKE = /^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*:\/\/)/i;
@@ -54,7 +83,7 @@ const pathLike = (piece) =>
54
83
 
55
84
  /** The same for a launch argument or a server's command and URL: a long run is looked for between path separators. */
56
85
  export const secretArgument = (value) =>
57
- JWT_SHAPE.test(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
86
+ hasJwt(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
58
87
  value.split(/[\s=]/).some((piece) => (pathLike(piece) ? piece.split(/[\\/]/).some((part) => longSecret(part)) : longSecret(piece)));
59
88
 
60
89
  const decodeKeepingPlus = (raw) => { try { return decodeURIComponent(raw); } catch { return raw; } };
@@ -91,7 +120,10 @@ export function secretUrl(value) {
91
120
  const REPOSITORY_URI = /^([A-Za-z][A-Za-z0-9+.-]*:(?:\/\/[^/?#]*)?)([^?#]*)([\s\S]*)$/;
92
121
  const REPOSITORY_NAME = /^(?:[a-z0-9._-]+|[A-Za-z._-]+)$/; // lower case, or letters with no digit (readings 8)
93
122
  const REPOSITORY_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
94
- const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${SECRET_VALUE.source})`, 'i');
123
+ // A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
124
+ // boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
125
+ const REPOSITORY_KEYS = 'sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}';
126
+ const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${REPOSITORY_KEYS})`);
95
127
  const JWT_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))${JWT_SHAPE.source}`);
96
128
  const SPECIAL_QUERY = /^(?:https?|wss?|ftp|file):/i; // whose query also encodes `'`, as the URL standard does
97
129
 
@@ -115,7 +147,8 @@ export function repositoryPassword(value) {
115
147
 
116
148
  // A repository's parameters, read as fabric_secrets.secret_url reads them, by hand: `new URL`
117
149
  // refuses a host it cannot parse and a special scheme written without `//` (`https:host/r?access_token=...`), where
118
- // 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.
119
152
  function repositoryParts(head, tail) {
120
153
  const text = tail.replace(/[\x00-\x20]+$/, '').replace(/[\t\n\r]/g, '');
121
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,
@@ -107,6 +110,10 @@ export {
107
110
  STATUSES,
108
111
  PLACEMENTS,
109
112
  MANAGERS,
113
+ PEER_CHECKS,
114
+ connectionRefusal,
115
+ connectionOwner,
116
+ loopbackServer,
110
117
  descriptorPath,
111
118
  readDescriptors,
112
119
  validateDescriptor,
@@ -185,6 +192,7 @@ export {
185
192
  taskUninstall,
186
193
  launchdPlist,
187
194
  SYSTEM_DAEMONS,
195
+ USER_NAME_RULE,
188
196
  launchdDomain,
189
197
  wellKnownOf,
190
198
  jobWellKnownOf,
@@ -343,6 +343,8 @@ function guiDomain(platform, uid) {
343
343
  /** Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278). */
344
344
  export const SYSTEM_DAEMONS = '/Library/LaunchDaemons';
345
345
  const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
346
+ /** The short-name rule in words, printed alike by both twins (FAA-286 item 13). */
347
+ export const USER_NAME_RULE = 'a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -';
346
348
 
347
349
  /**
348
350
  * The launchd domain a job lives in: `system` for a LaunchDaemon (`domain: 'system'`, or a plist in
@@ -351,6 +353,9 @@ const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
351
353
  */
352
354
  export function launchdDomain(plistPath = null, { domain = null, platform = process.platform, uid } = {}) {
353
355
  if (![null, undefined, 'gui', 'system'].includes(domain)) throw new ServiceError('domain must be gui or system');
356
+ if (plistPath !== null && plistPath !== undefined && /^(?:system|gui)(?:\/[0-9]*)?$/.test(String(plistPath))) {
357
+ throw new ServiceError(`${plistPath} is a launchd domain, not a plist path: pass { domain: '${String(plistPath) === 'system' ? 'system' : 'gui'}' } (FAA-286)`);
358
+ }
354
359
  let which = domain;
355
360
  if ((which === null || which === undefined) && plistPath !== null && plistPath !== undefined) {
356
361
  const paths = platform === 'darwin' ? path.posix : path; // a macOS path, whatever host reads it
@@ -363,6 +368,34 @@ export function launchdDomain(plistPath = null, { domain = null, platform = proc
363
368
  return guiDomain(platform, uid);
364
369
  }
365
370
 
371
+ // A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd also
372
+ // loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root). Another
373
+ // folder is allowed for a system job, as the tests' and a staging install's are.
374
+ function pairProblem(plistPath, domain, platform) {
375
+ const paths = platform === 'darwin' ? path.posix : path;
376
+ const folder = paths.dirname(paths.resolve(String(plistPath)));
377
+ const parts = String(plistPath).split(/[\\/]+/); // the folder's own name, whatever separator a test's host path uses
378
+ if (domain === 'system' && String(parts[parts.length - 2] ?? '').toLowerCase() === 'launchagents') {
379
+ return `A LaunchDaemon's plist lives in ${SYSTEM_DAEMONS}: ${plistPath} is in a LaunchAgents folder, which launchd also loads into gui/<uid> at login, so the job would run twice.`;
380
+ }
381
+ if (domain === 'gui' && folder.toLowerCase() === SYSTEM_DAEMONS.toLowerCase()) {
382
+ return `${plistPath} is in ${SYSTEM_DAEMONS}, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in ~/Library/LaunchAgents.`;
383
+ }
384
+ return null;
385
+ }
386
+
387
+ // A LaunchAgent of `label` in `userName`'s ~/Library/LaunchAgents, which would run a second copy at its login (FAA-286
388
+ // item 6); null when there is none or the home cannot be read.
389
+ function leftoverAgent(label, userName, run) {
390
+ const read = run(['dscl', '.', '-read', `/Users/${userName}`, 'NFSHomeDirectory']);
391
+ const home = read?.status === 0 ? /NFSHomeDirectory:\s*(\S.*?)\s*$/m.exec(String(read.stdout ?? ''))?.[1] : null;
392
+ if (!home) return null;
393
+ const folder = path.join(home, 'Library', 'LaunchAgents');
394
+ let names = [];
395
+ try { names = fs.readdirSync(folder).filter((n) => n.endsWith('.plist')).sort(); } catch { return null; }
396
+ return names.map((n) => path.join(folder, n)).find((file) => topKey(plistXml(file, run), 'Label') === label) ?? null;
397
+ }
398
+
366
399
  // A system-domain job is installed and removed by root: refuse before anything is written (FAA-278).
367
400
  function needRoot(domain, label, plistPath, install, euid) {
368
401
  if (domain !== 'system' || euid === 0) return;
@@ -390,6 +423,9 @@ function decodeXml(text) {
390
423
  function topStrings(text) {
391
424
  const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z][\w.:-]*)\s*[^>]*?(\/?)>|([^<]+)/g;
392
425
  const values = Object.create(null);
426
+ // The open containers: only the root dict's own keys are read (FAA-286 item 10: a root <array> holding a
427
+ // <key>UserName</key> is no dict, as plistlib reads it).
428
+ const open = [];
393
429
  let depth = 0;
394
430
  let key = null;
395
431
  let capture = null;
@@ -398,17 +434,21 @@ function topStrings(text) {
398
434
  for (let m = token.exec(String(text ?? '')); m; m = token.exec(String(text ?? ''))) {
399
435
  const [, cdata, closing, name, selfClosing, chars] = m;
400
436
  if (cdata !== undefined || chars !== undefined) {
401
- if (capture) buffer += cdata !== undefined ? cdata : decodeXml(chars);
437
+ // every character reference, at any depth and in any value, is checked as expat checks it (FAA-286 item 10)
438
+ const decoded = chars !== undefined ? decodeXml(chars) : cdata;
439
+ if (capture) buffer += decoded;
402
440
  } else if (name === 'dict' || name === 'array') {
403
441
  if (selfClosing) {
404
442
  if (depth === 1 && key !== null) { delete values[key]; key = null; }
405
443
  } else if (closing) {
406
444
  depth -= 1;
445
+ open.pop();
407
446
  } else {
408
447
  if (depth === 1 && key !== null) { delete values[key]; key = null; } // a container is this key's value
409
448
  depth += 1;
449
+ open.push(name);
410
450
  }
411
- } else if (name && depth === 1) {
451
+ } else if (name && depth === 1 && open[0] === 'dict') {
412
452
  if (selfClosing) {
413
453
  if (key !== null) {
414
454
  if (name === 'string') values[key] = '';
@@ -431,6 +471,7 @@ function topStrings(text) {
431
471
  } catch {
432
472
  return null;
433
473
  }
474
+ if (key !== null || capture !== null || depth !== 0) return null; // a dangling <key> or an open element: unreadable
434
475
  return values;
435
476
  }
436
477
 
@@ -569,8 +610,8 @@ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
569
610
 
570
611
  // Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
571
612
  // which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
572
- async function jobListens(pid, port, run = runProgram) {
573
- if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
613
+ async function jobListens(pid, port, run = runProgram, platform = process.platform) {
614
+ if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
574
615
  try {
575
616
  const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
576
617
  return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
@@ -600,7 +641,11 @@ class JobAgent extends http.Agent {
600
641
  this.ask = ask;
601
642
  }
602
643
 
603
- createConnection(options, callback) {
644
+ createConnection(options, given) {
645
+ // One answer per connection (FAA-286 item 12): an error during the async owner, lsof and ps checks would reach the
646
+ // callback a second time, as CheckedAgent's checkedSocket guards it.
647
+ let answered = false;
648
+ const callback = (err, socket) => { if (!answered) { answered = true; given(err, socket); } };
604
649
  const port = Number(options.port);
605
650
  const socket = net.connect({ host: '127.0.0.1', port, noDelay: true });
606
651
  const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), options.timeout ?? 10000);
@@ -614,14 +659,14 @@ class JobAgent extends http.Agent {
614
659
  } catch (err) {
615
660
  who = { kind: UNKNOWN, uid: null, pid: null, reason: `check: ${err?.name ?? 'Error'}` };
616
661
  }
617
- if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run)) {
662
+ if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run, this.ask.platform)) {
618
663
  socket.destroy();
619
664
  // The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
620
665
  callback(new ListenerRefused(SERVER_NOT_OWN, `the LaunchDaemon's pid ${this.jobPid} is not shown listening on exactly 127.0.0.1:${port} by lsof (a wildcard is never vouched for); nothing was sent`));
621
666
  return;
622
667
  }
623
668
  const proved = who.pid === this.jobPid || (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED)
624
- && await jobListens(this.jobPid, port, this.ask.run));
669
+ && await jobListens(this.jobPid, port, this.ask.run, this.ask.platform));
625
670
  if (!proved) {
626
671
  socket.destroy();
627
672
  callback(new ListenerRefused(SERVER_NOT_OWN, `the server on port ${port} is not the LaunchDaemon's pid ${this.jobPid} (${who.reason ?? who.kind}): another process holds the port, or the job's program starts the server as a child (start it with exec); nothing was sent`, who));
@@ -649,11 +694,11 @@ function loopbackPort(origin) {
649
694
  }
650
695
 
651
696
  // As wellKnownOf, for a LaunchDaemon: a loopback origin is read only when launchd's job `jobPid`, running as `jobUid`,
652
- // accepted the connection.
653
- export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
697
+ // accepted the connection. `ask` reaches the owner, lsof and ps readers (a test's fakes; `platform` for lsof's).
698
+ export async function jobWellKnownOf(origin, { jobPid, jobUid, ask = {} } = {}) {
654
699
  const port = loopbackPort(origin);
655
700
  if (port === null) throw new ServiceError(`A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid prove it; ${origin} is not.`);
656
- return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid }));
701
+ return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid, ask }));
657
702
  }
658
703
 
659
704
  /**
@@ -672,9 +717,15 @@ export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
672
717
  export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
673
718
  run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), fetchWellKnown = wellKnownOf,
674
719
  fetchJobWellKnown = jobWellKnownOf, sleep = pause } = {}) {
720
+ const pair = pairProblem(plistPath, domain, platform);
721
+ if (pair) throw new ServiceError(pair);
675
722
  const dom = launchdDomain(plistPath, { domain, platform, uid });
676
723
  launchdLabel(label);
677
724
  if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
725
+ if (/^bplist/.test(Buffer.isBuffer(plistText) ? plistText.toString('latin1', 0, 6) : String(plistText ?? '').slice(0, 6))) {
726
+ // named as what it is, never as a missing UserName (FAA-286 item 7): the text launchdPlist writes is XML
727
+ throw new ServiceError('The plist is a binary plist: pass the XML text launchdPlist writes (plutil -convert xml1 converts one).');
728
+ }
678
729
  needRoot(dom, label, plistPath, true, euid);
679
730
  if (dom === 'system' && loopbackPort(origin) === null) {
680
731
  throw new ServiceError(`A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by the job's pid and uid (FAA-278); ${origin} is not.`);
@@ -693,7 +744,7 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
693
744
  }
694
745
  }
695
746
  if (dom === 'system' && named && !USER_NAME.test(named)) {
696
- throw new ServiceError(`UserName ${named} is not a macOS short name (${USER_NAME.source}): launchd takes the account's short name.`);
747
+ throw new ServiceError(`UserName ${named} is not a macOS short name (${USER_NAME_RULE}): launchd takes the account's short name.`);
697
748
  }
698
749
  const jobUid = named ? accountUid(named, run) : null;
699
750
  if (dom === 'system' && named && jobUid === null) {
@@ -705,7 +756,12 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
705
756
  if (dom === 'system' && !named) {
706
757
  throw new ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with launchdPlist(..., { userName: <the account whose Fabric and agents call it> }).");
707
758
  }
759
+ if (dom === 'system') {
760
+ const leftover = leftoverAgent(label, named, run);
761
+ if (leftover) throw new ServiceError(`${leftover} is a LaunchAgent of label ${label}: it would start a second copy at ${named}'s login beside this LaunchDaemon. Remove it first (launchdUninstall as ${named}; lifecycle reference, The system domain).`);
762
+ }
708
763
  const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
764
+ const existed = fs.existsSync(plistPath);
709
765
  atomicWrite(plistPath, plistText, 0o644);
710
766
  const lint = run(['plutil', '-lint', String(plistPath)]);
711
767
  if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
@@ -725,6 +781,11 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
725
781
  if (dom !== 'system' && /:\s*125:|Domain does not support|Could not find domain/.test(last)) {
726
782
  throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}. This user has no GUI login session (an SSH session, or a headless Mac after a FileVault restart), so ${dom} does not run jobs: remove the LaunchAgent this attempt wrote (launchdUninstall), then install it as a LaunchDaemon in the system domain (a plist in ${SYSTEM_DAEMONS} with UserName, as root; FAA-278).`);
727
783
  }
784
+ if (dom === 'system') {
785
+ // a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
786
+ if (!existed) { try { fs.unlinkSync(plistPath); } catch { /* named below */ } }
787
+ throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}; ${fs.existsSync(plistPath) ? `${plistPath} stays and loads at the next boot: remove it as root` : 'the plist this call wrote was removed'}`);
788
+ }
728
789
  throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
729
790
  }
730
791
  await sleep(1000);
@@ -780,6 +841,10 @@ export async function launchdUninstall(label, plistPath, { timeout = 30, purge =
780
841
  needRoot(dom, label, plistPath, false, euid);
781
842
  if (purge && dom === 'system') throw new ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then remove the data as that user.");
782
843
  if (dom === 'system') refuseHeldLabel(label, plistPath, run); // a bootout would stop another plist's job (FAA-278)
844
+ const holds = fs.existsSync(plistPath) ? topKey(plistXml(plistPath, run), 'Label') : null;
845
+ if (holds !== null && holds !== label) { // FAA-286 item 9: booting out one job and unlinking another's plist
846
+ throw new ServiceError(`${plistPath} holds launchd label ${holds}, not ${label}: nothing was removed. Name the plist of ${label}, or uninstall ${holds} with its own label.`);
847
+ }
783
848
  const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
784
849
  const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
785
850
  const target = `${dom}/${label}`;
@@ -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
 
@@ -5,12 +5,14 @@ from __future__ import annotations
5
5
 
6
6
  import hashlib
7
7
  import http.server
8
+ import ipaddress
8
9
  import json
9
10
  import os
10
11
  import re
11
12
  import socket
12
13
  import socketserver
13
14
  import subprocess
15
+ import threading
14
16
  import warnings
15
17
  from pathlib import Path
16
18
  from typing import Any, Dict, List, Optional, Sequence, Tuple
@@ -53,7 +55,7 @@ from fabric_usage import (
53
55
  DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
54
56
 
55
57
 
56
- DASHBOARD_KIT = "fabric-dashboard-kit@1.1.6"
58
+ DASHBOARD_KIT = "fabric-dashboard-kit@1.1.7"
57
59
 
58
60
 
59
61
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
@@ -88,19 +90,101 @@ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
88
90
  _AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
89
91
 
90
92
 
93
+ #: How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
94
+ #: FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of
95
+ #: it and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account
96
+ #: other than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table.
97
+ PEER_CHECKS = ("refuse", "strict", "off")
98
+
99
+
100
+ def connection_refusal(owner: Any, mode: str = "refuse") -> Optional[str]:
101
+ """Why a server refuses the peer `owner` (a fabric_peer_owner.Owner) under `mode`, or None to serve it. The reason
102
+ is the owner's kind, or for `unknown` its reason; it names no address and nothing the peer sent."""
103
+ if mode not in PEER_CHECKS:
104
+ raise ValueError("peer_check must be one of %s" % ", ".join(PEER_CHECKS))
105
+ if mode == "off" or owner is None:
106
+ return None
107
+ kind = getattr(owner, "kind", fabric_peer_owner.UNKNOWN)
108
+ if kind == fabric_peer_owner.ANOTHER_ACCOUNT:
109
+ return kind
110
+ if mode == "strict" and kind != fabric_peer_owner.THIS_ACCOUNT:
111
+ if kind == fabric_peer_owner.SYSTEM and getattr(owner, "uid", None) == 0:
112
+ return None
113
+ return getattr(owner, "reason", None) or kind
114
+ return None
115
+
116
+
117
+ def is_loopback_peer(client_address: Any) -> bool:
118
+ """Whether an accepted connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the
119
+ only peers whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read
120
+ counts as loopback, so its owner is still read from the table."""
121
+ try:
122
+ return ipaddress.ip_address(fabric_peer_owner.norm_host(str(client_address[0]))).is_loopback
123
+ except Exception:
124
+ return True
125
+
126
+
91
127
  class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
92
128
  """ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
93
129
  no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
94
130
  on a Mac with a slow resolver the port then stays bound but silent, so a host sees
95
- neither an answer nor a refusal."""
131
+ neither an answer nor a refusal.
132
+
133
+ Each accepted connection's peer is told from the computer's socket table (`fabric_peer_owner.peer`) in the
134
+ connection's own thread, before the handler reads a byte (DEC-0154 HA-2's server side, FAA-227). `peer_check`
135
+ (`PEER_CHECKS`) decides which owners are closed unread; a served handler finds its peer's owner with
136
+ `self.server.peer_owner()`. A refusal writes one line to stderr with the owner's kind, uid and pid and nothing the
137
+ peer sent (`peer_refused`), and counts in `peer_refusals`. The check applies to loopback peers only: a peer at
138
+ another address (a service bound to 0.0.0.0) is no local account, so its table is not read and it is served under
139
+ `refuse` and `strict` alike, with `peer_owner()` None."""
96
140
 
97
141
  daemon_threads = True
142
+ peer_check = "refuse"
143
+ #: The reader of an accepted socket's peer; a test replaces it.
144
+ peer_reader = staticmethod(lambda sock: fabric_peer_owner.peer(sock))
145
+
146
+ def __init__(self, server_address: Any, handler: Any, bind_and_activate: bool = True, *,
147
+ peer_check: Optional[str] = None) -> None:
148
+ mode = self.peer_check if peer_check is None else peer_check
149
+ connection_refusal(None, mode) # refuses an unknown mode before the port is bound
150
+ self.peer_check = mode
151
+ self.peer_refusals = 0
152
+ self._peer = threading.local()
153
+ super().__init__(server_address, handler, bind_and_activate)
98
154
 
99
155
  def server_bind(self) -> None:
100
156
  socketserver.TCPServer.server_bind(self)
101
157
  host, port = self.server_address[:2]
102
158
  self.server_name, self.server_port = host, port
103
159
 
160
+ def peer_owner(self) -> Any:
161
+ """The owner of the peer this thread's handler serves (None under `peer_check="off"`)."""
162
+ return getattr(self._peer, "owner", None)
163
+
164
+ def finish_request(self, request: Any, client_address: Any) -> None:
165
+ owner = None
166
+ if self.peer_check != "off" and is_loopback_peer(client_address):
167
+ try:
168
+ owner = self.peer_reader(request)
169
+ except Exception as exc: # a reader that fails is an owner nobody can tell, never a crash of the server
170
+ owner = fabric_peer_owner.Owner(fabric_peer_owner.UNKNOWN, None, None, "none",
171
+ "reader: %s" % type(exc).__name__)
172
+ reason = connection_refusal(owner, self.peer_check)
173
+ if reason is not None:
174
+ self.peer_refusals += 1
175
+ self.peer_refused(owner, reason)
176
+ return # process_request_thread closes the socket unread (shutdown_request)
177
+ self._peer.owner = owner
178
+ try:
179
+ super().finish_request(request, client_address)
180
+ finally:
181
+ self._peer.owner = None
182
+
183
+ def peer_refused(self, owner: Any, reason: str) -> None:
184
+ """One line on stderr per refused connection; override to log it elsewhere. Never the peer's bytes."""
185
+ _sys.stderr.write("fabric: refused a loopback peer (%s, uid %s, pid %s) before reading it (DEC-0154)\n"
186
+ % (reason, getattr(owner, "uid", None), getattr(owner, "pid", None)))
187
+
104
188
 
105
189
  def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
106
190
  return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))