@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.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 (120) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +50 -5
  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/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  18. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  19. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -6
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  91. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  92. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  93. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  94. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  95. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  96. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  106. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -115,12 +115,33 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
115
115
 
116
116
  const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
117
117
 
118
+ // FAA-253: the well-known answer at `origin`, polled until this OS account's listener answers as serviceId.instance, as
119
+ // launchdInstall does (the pinned contract's Install row: every supervisor polls). Another account's listener, or one
120
+ // whose owner cannot be told, is never a successful install (DEC-0154 HA-2).
121
+ async function pollInstalled(origin, { serviceId, instance = 'default', timeout = 40, fetchWellKnown = wellKnownOf, sleep = pause, clock = Date.now } = {}) {
122
+ if (!serviceId) throw new ServiceError(`polling the install at ${origin} needs the serviceId it must answer as`);
123
+ const deadline = clock() + Math.max(0, timeout) * 1000;
124
+ let foreign = null;
125
+ for (;;) {
126
+ const doc = await fetchWellKnown(origin);
127
+ foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
128
+ if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
129
+ if (clock() >= deadline) break;
130
+ await sleep(500);
131
+ }
132
+ if (foreign) throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
133
+ throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
134
+ }
135
+
118
136
  /**
119
137
  * Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
120
138
  * The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
121
- * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
139
+ * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on). With `origin`
140
+ * (and `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to the unit file once this
141
+ * account's listener answers (FAA-253); without `origin` it stays synchronous.
122
142
  */
123
- export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
143
+ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false,
144
+ origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
124
145
  if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
125
146
  if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
126
147
  fs.mkdirSync(unitDir, { recursive: true });
@@ -128,10 +149,14 @@ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defau
128
149
  const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
129
150
  atomicWrite(file, text, 0o644);
130
151
  checkRun(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
131
- if (state === 'disabled' && !forceEnable) return file;
132
- checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
133
- checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
134
- return file;
152
+ const started = !(state === 'disabled' && !forceEnable);
153
+ if (started) {
154
+ checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
155
+ checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
156
+ }
157
+ if (origin === undefined) return file;
158
+ // FAA-253: with an origin it resolves once this account's listener answers; a unit kept off is not polled.
159
+ return started ? pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock }).then(() => file) : Promise.resolve(file);
135
160
  }
136
161
 
137
162
  /** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
@@ -203,9 +228,12 @@ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Ena
203
228
 
204
229
  /**
205
230
  * Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
206
- * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
231
+ * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`. With `origin` (and
232
+ * `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to it (FAA-253); a task kept off
233
+ * resolves to undefined, unpolled. Without `origin` it stays synchronous.
207
234
  */
208
- export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
235
+ export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false,
236
+ origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
209
237
  if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
210
238
  const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
211
239
  const existed = query?.status === 0;
@@ -221,10 +249,11 @@ export function taskInstall(task, xmlText, { run = defaultRun, platform = proces
221
249
  }
222
250
  if (keepOff) {
223
251
  checkRun(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
224
- return;
252
+ return origin === undefined ? undefined : Promise.resolve(undefined);
225
253
  }
226
254
  if (existed) run(['schtasks', '/End', '/TN', task]);
227
255
  checkRun(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
256
+ return origin === undefined ? undefined : pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock });
228
257
  }
229
258
 
230
259
  /** End the running task, then delete it. */
@@ -314,6 +343,8 @@ function guiDomain(platform, uid) {
314
343
  /** Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278). */
315
344
  export const SYSTEM_DAEMONS = '/Library/LaunchDaemons';
316
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 -';
317
348
 
318
349
  /**
319
350
  * The launchd domain a job lives in: `system` for a LaunchDaemon (`domain: 'system'`, or a plist in
@@ -322,6 +353,9 @@ const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
322
353
  */
323
354
  export function launchdDomain(plistPath = null, { domain = null, platform = process.platform, uid } = {}) {
324
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
+ }
325
359
  let which = domain;
326
360
  if ((which === null || which === undefined) && plistPath !== null && plistPath !== undefined) {
327
361
  const paths = platform === 'darwin' ? path.posix : path; // a macOS path, whatever host reads it
@@ -334,6 +368,34 @@ export function launchdDomain(plistPath = null, { domain = null, platform = proc
334
368
  return guiDomain(platform, uid);
335
369
  }
336
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
+
337
399
  // A system-domain job is installed and removed by root: refuse before anything is written (FAA-278).
338
400
  function needRoot(domain, label, plistPath, install, euid) {
339
401
  if (domain !== 'system' || euid === 0) return;
@@ -361,6 +423,9 @@ function decodeXml(text) {
361
423
  function topStrings(text) {
362
424
  const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z][\w.:-]*)\s*[^>]*?(\/?)>|([^<]+)/g;
363
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 = [];
364
429
  let depth = 0;
365
430
  let key = null;
366
431
  let capture = null;
@@ -369,17 +434,21 @@ function topStrings(text) {
369
434
  for (let m = token.exec(String(text ?? '')); m; m = token.exec(String(text ?? ''))) {
370
435
  const [, cdata, closing, name, selfClosing, chars] = m;
371
436
  if (cdata !== undefined || chars !== undefined) {
372
- 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;
373
440
  } else if (name === 'dict' || name === 'array') {
374
441
  if (selfClosing) {
375
442
  if (depth === 1 && key !== null) { delete values[key]; key = null; }
376
443
  } else if (closing) {
377
444
  depth -= 1;
445
+ open.pop();
378
446
  } else {
379
447
  if (depth === 1 && key !== null) { delete values[key]; key = null; } // a container is this key's value
380
448
  depth += 1;
449
+ open.push(name);
381
450
  }
382
- } else if (name && depth === 1) {
451
+ } else if (name && depth === 1 && open[0] === 'dict') {
383
452
  if (selfClosing) {
384
453
  if (key !== null) {
385
454
  if (name === 'string') values[key] = '';
@@ -402,6 +471,7 @@ function topStrings(text) {
402
471
  } catch {
403
472
  return null;
404
473
  }
474
+ if (key !== null || capture !== null || depth !== 0) return null; // a dangling <key> or an open element: unreadable
405
475
  return values;
406
476
  }
407
477
 
@@ -540,8 +610,8 @@ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
540
610
 
541
611
  // Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
542
612
  // which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
543
- async function jobListens(pid, port, run = runProgram) {
544
- 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;
545
615
  try {
546
616
  const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
547
617
  return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
@@ -571,7 +641,11 @@ class JobAgent extends http.Agent {
571
641
  this.ask = ask;
572
642
  }
573
643
 
574
- 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); } };
575
649
  const port = Number(options.port);
576
650
  const socket = net.connect({ host: '127.0.0.1', port, noDelay: true });
577
651
  const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), options.timeout ?? 10000);
@@ -585,14 +659,14 @@ class JobAgent extends http.Agent {
585
659
  } catch (err) {
586
660
  who = { kind: UNKNOWN, uid: null, pid: null, reason: `check: ${err?.name ?? 'Error'}` };
587
661
  }
588
- 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)) {
589
663
  socket.destroy();
590
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.
591
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`));
592
666
  return;
593
667
  }
594
668
  const proved = who.pid === this.jobPid || (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED)
595
- && await jobListens(this.jobPid, port, this.ask.run));
669
+ && await jobListens(this.jobPid, port, this.ask.run, this.ask.platform));
596
670
  if (!proved) {
597
671
  socket.destroy();
598
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));
@@ -620,11 +694,11 @@ function loopbackPort(origin) {
620
694
  }
621
695
 
622
696
  // As wellKnownOf, for a LaunchDaemon: a loopback origin is read only when launchd's job `jobPid`, running as `jobUid`,
623
- // accepted the connection.
624
- 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 = {} } = {}) {
625
699
  const port = loopbackPort(origin);
626
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.`);
627
- return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid }));
701
+ return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid, ask }));
628
702
  }
629
703
 
630
704
  /**
@@ -643,9 +717,15 @@ export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
643
717
  export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
644
718
  run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), fetchWellKnown = wellKnownOf,
645
719
  fetchJobWellKnown = jobWellKnownOf, sleep = pause } = {}) {
720
+ const pair = pairProblem(plistPath, domain, platform);
721
+ if (pair) throw new ServiceError(pair);
646
722
  const dom = launchdDomain(plistPath, { domain, platform, uid });
647
723
  launchdLabel(label);
648
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
+ }
649
729
  needRoot(dom, label, plistPath, true, euid);
650
730
  if (dom === 'system' && loopbackPort(origin) === null) {
651
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.`);
@@ -664,7 +744,7 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
664
744
  }
665
745
  }
666
746
  if (dom === 'system' && named && !USER_NAME.test(named)) {
667
- 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.`);
668
748
  }
669
749
  const jobUid = named ? accountUid(named, run) : null;
670
750
  if (dom === 'system' && named && jobUid === null) {
@@ -676,7 +756,12 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
676
756
  if (dom === 'system' && !named) {
677
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> }).");
678
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
+ }
679
763
  const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
764
+ const existed = fs.existsSync(plistPath);
680
765
  atomicWrite(plistPath, plistText, 0o644);
681
766
  const lint = run(['plutil', '-lint', String(plistPath)]);
682
767
  if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
@@ -696,6 +781,11 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
696
781
  if (dom !== 'system' && /:\s*125:|Domain does not support|Could not find domain/.test(last)) {
697
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).`);
698
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
+ }
699
789
  throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
700
790
  }
701
791
  await sleep(1000);
@@ -751,6 +841,10 @@ export async function launchdUninstall(label, plistPath, { timeout = 30, purge =
751
841
  needRoot(dom, label, plistPath, false, euid);
752
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.");
753
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
+ }
754
848
  const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
755
849
  const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
756
850
  const target = `${dom}/${label}`;
@@ -70,7 +70,14 @@ function byProject(receipts, first, last) {
70
70
  const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
71
71
  return [...projects.keys()].sort().map((key) => {
72
72
  const { runs, ...row } = projects.get(key);
73
- const taskRuns = [...runs.keys()].sort().map((id) => close(runs.get(id)));
73
+ // A run's cost rounds as the project's does but never above its exact cost: each run and the project round apart,
74
+ // so many runs rounding up summed past the project and failed FAC-SEM-025 (board request f5753c5a).
75
+ const closeRun = (run) => {
76
+ const out = close(run);
77
+ if (out.costUsd !== null && out.costUsd > run.cost) out.costUsd = Math.round((out.costUsd - 0.000001) * 1e6) / 1e6;
78
+ return out;
79
+ };
80
+ const taskRuns = [...runs.keys()].sort().map((id) => closeRun(runs.get(id)));
74
81
  return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
75
82
  });
76
83
  }
@@ -168,6 +168,7 @@ class Responder(SlotKeeper):
168
168
  self.state = "idle"
169
169
  self.wait_until = 0.0
170
170
  self.stopping = False
171
+ self.shutting_down, self.draining = threading.Event(), None # shutdown began; it found work to drain (FAA-186)
171
172
  self.claim_keys: Dict[str, str] = {}
172
173
  self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
173
174
  self._said: Dict[str, str] = {}
@@ -982,8 +983,8 @@ class Responder(SlotKeeper):
982
983
  def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
983
984
  """Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
984
985
  (its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
985
- self.stopping = True
986
- work = self.current
986
+ self.stopping, work = True, self.current
987
+ self.draining = work is not None; self.shutting_down.set() # noqa: E702 - published before it waits (FAA-186)
987
988
  if work is not None and not work.done.wait(drain):
988
989
  work.ctx.stopped.set()
989
990
  if work is not None and work.done.is_set() and self.session is not None:
@@ -247,3 +247,5 @@ class SlotKeeper:
247
247
  self._retry_later(capability, entries or [], exc)
248
248
  if exc.code in SESSION_CODES:
249
249
  raise
250
+ if exc.code == "idempotency_window_expired":
251
+ self._status() # the epoch moved: read it before the retry, which no held slot would do (FAA-186)
@@ -4,8 +4,10 @@
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
7
8
  import ipaddress
8
9
  import re
10
+ import unicodedata
9
11
  import urllib.parse
10
12
  from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
11
13
 
@@ -173,6 +175,92 @@ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
173
175
  _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
174
176
 
175
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
+
176
264
  def _ipv4_number(part: str) -> Optional[int]:
177
265
  if part == "":
178
266
  return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
@@ -198,10 +286,16 @@ def _domain_ok(host: str) -> bool:
198
286
  return False
199
287
  if any(c in _FORBIDDEN_DOMAIN for c in host):
200
288
  return False
201
- # A non-ASCII host goes through UTS46 in WHATWG; this module does not port it. When unsure, the host is taken as
202
- # valid, so its URL's credentials are read: the doubt then hides a list rather than publish one (round-2 reading).
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).
203
292
  if any(ord(c) > 0x7f for c in host):
204
- return True
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
205
299
  labels = host.split(".")
206
300
  if labels and labels[-1] == "" and len(labels) > 1:
207
301
  labels = labels[:-1]
@@ -13,7 +13,8 @@ not re-export this module, so a service that does not run per context keeps the
13
13
  An organization's instance whose context the record does not pin (``organizations``, DEC-0127) still starts: the helper
14
14
  checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
15
15
  ``fabric_estate.hub_key_certified`` reads a context's hub keys only while the record pins that organization (DL-14a,
16
- DEC-0128), so the service passes ``estate_context=info["context"]`` to its delegation gate. The contract lets Fabric
16
+ DEC-0128), so the service passes ``context_gate_options(info, record)`` (its context and its pin's issuer, FAA-150) to its
17
+ delegation gate. The contract lets Fabric
17
18
  write ``organizations`` only once every reader is pinned at or after DEC-0127, so a refusal at start would stop every
18
19
  organization instance on today's records."""
19
20
  from __future__ import annotations
@@ -33,7 +34,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
33
34
  from fabric_base import ServiceError, _INSTANCE # noqa: E402
34
35
  from fabric_descriptor import descriptor_path # noqa: E402
35
36
  from fabric_estate import parse_context # noqa: E402
36
- from fabric_lock import InstanceLock, hold_single_instance # noqa: E402
37
+ from fabric_lock import (InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, hold_single_instance, # noqa: E402
38
+ lock_wait_budget_seconds, supervisor_marker)
37
39
  from fabric_paths import atomic_write, instance_dirs # noqa: E402
38
40
  from fabric_share import instance_share_grant # noqa: E402
39
41
  from fabric_time import clock_ms # noqa: E402
@@ -125,9 +127,49 @@ def context_instance(service_id: str, context: Any, *, record: Optional[Dict[str
125
127
  return answer
126
128
 
127
129
 
128
- def _refuse(service_id: Any, context: Any, code: str) -> None:
129
- print("%s will not run for %s (%s): %s." % (service_id, _shown(context), code, _REASONS[code]), file=sys.stderr, flush=True)
130
- raise SystemExit(EXIT_CONTEXT_REFUSED)
130
+ def context_gate_options(info: Any, record: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
131
+ """What ``delegation_gate`` takes from this instance's context (FAA-150): ``estate_context``, and for an organization
132
+ whose context ``record`` pins, ``issuer``: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance
133
+ refuses a token the personal hub key signed without wiring either by hand. ``info`` is ``context_instance``'s answer
134
+ (or ``hold_context_instance``'s); ``record`` the verified estate revision applied. Spread it after the service's own
135
+ estate issuer: ``delegation_gate(..., **{"issuer": estate_issuer, **context_gate_options(info, record)})``. Raises
136
+ ContextRefused (``context-invalid``) for an answer with no context."""
137
+ context = info.get("context") if isinstance(info, dict) else None
138
+ parsed = parse_context(context)
139
+ if parsed is None:
140
+ raise ContextRefused("context-invalid")
141
+ options: Dict[str, Any] = {"estate_context": context}
142
+ pins = record.get("organizations") if isinstance(record, dict) else None
143
+ if parsed["kind"] == "org" and isinstance(pins, list):
144
+ for pin in pins:
145
+ if isinstance(pin, dict) and pin.get("org") == context and isinstance(pin.get("iss"), str):
146
+ options["issuer"] = pin["iss"]
147
+ break
148
+ return options
149
+
150
+
151
+ def _refuse(service_id: Any, context: Any, code: str, wait_seconds: Optional[float] = 0.0, *,
152
+ sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
153
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> None:
154
+ """One sentence naming the code, then exit 78. Under a supervisor that restarts on exit (launchd's KeepAlive with
155
+ ThrottleInterval 10, systemd's Restart=always) an exit at once is a respawn every few seconds, forever (FAA-279);
156
+ so a supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing, and
157
+ exits 78 when it runs out: a few restarts an hour. ``wait_seconds`` overrides the choice as the lock's does."""
158
+ if wait_seconds is None:
159
+ wait_seconds = lock_wait_budget_seconds(supervisor_marker(env, argv))
160
+ wait_seconds = max(0.0, wait_seconds)
161
+ tail = " Waiting %.0f s before exiting, so its supervisor does not restart it every few seconds." % wait_seconds \
162
+ if wait_seconds > 0 else ""
163
+ print("%s will not run for %s (%s): %s.%s" % (service_id, _shown(context), code, _REASONS[code], tail),
164
+ file=sys.stderr, flush=True)
165
+ deadline = clock() + wait_seconds
166
+ delay = LOCK_BACKOFF_FIRST_SECONDS
167
+ while True:
168
+ remaining = deadline - clock()
169
+ if remaining <= 0:
170
+ raise SystemExit(EXIT_CONTEXT_REFUSED)
171
+ sleep(min(delay, remaining))
172
+ delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
131
173
 
132
174
 
133
175
  def _marker_matches(path: Path, expected: Dict[str, str]) -> Optional[bool]:
@@ -152,7 +194,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
152
194
  env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
153
195
  services_directory: Optional[Path] = None) -> Tuple[Dict[str, Any], InstanceLock]:
154
196
  """Run as ``context``'s one instance, before any side effect (brief D17): ``context_instance`` (a refusal prints one
155
- sentence naming the code and exits 78, nothing created); then ``hold_single_instance`` on the instance's data
197
+ sentence naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then ``hold_single_instance`` on the instance's data
156
198
  folder (its markers, its supervised wait, its exit 75); then the marker ``<data>/fabric-context.json``: absent, it
157
199
  is written (0600, atomic); equal, it is kept; another context or instance, or unreadable, releases the lock and
158
200
  exits 78 (``context-mismatch``). Returns the instance and the held lock."""
@@ -160,7 +202,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
160
202
  info = context_instance(service_id, context, record=record, machine_id=machine_id, agent_ref=agent_ref, at=at,
161
203
  personal_instance=personal_instance, services_directory=services_directory)
162
204
  except ContextRefused as refused:
163
- _refuse(service_id, context, refused.code)
205
+ _refuse(service_id, context, refused.code, wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
164
206
  lock = hold_single_instance(info["dirs"]["data"], wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
165
207
  expected = {"service": service_id, "instance": info["instance"], "context": context}
166
208
  marker = Path(info["dirs"]["data"]) / CONTEXT_MARKER
@@ -173,5 +215,5 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
173
215
  raise
174
216
  if matches is False:
175
217
  lock.release()
176
- _refuse(service_id, context, "context-mismatch")
218
+ _refuse(service_id, context, "context-mismatch", wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
177
219
  return info, lock