@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +14 -7
  3. package/bin/fabric-agent-adapter.js +26 -4
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.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 +42 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/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/references/agents-database.md +4 -4
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
  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-delegation.mjs +30 -2
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  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-lock.mjs +54 -20
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
  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_base.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  91. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
  93. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  94. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  95. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
  96. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
  97. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  98. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  99. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  100. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  101. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  102. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
  103. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
  104. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  105. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -27,6 +27,9 @@ export class AlreadyRunning extends ServiceError {
27
27
  }
28
28
 
29
29
  export class InstanceLock {
30
+ /** How many times acquire re-opens service.lock that moved under it before it gives up (FAA-293). */
31
+ static INODE_RETRIES = 3;
32
+
30
33
  constructor(dataDir) {
31
34
  this.path = path.join(dataDir, 'service.lock');
32
35
  this.fd = null;
@@ -36,27 +39,12 @@ export class InstanceLock {
36
39
  async acquire() {
37
40
  ensurePrivateDir(path.dirname(this.path));
38
41
  if (process.platform === 'darwin') {
39
- const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
40
- try {
41
- this.fd = fs.openSync(this.path, flags, 0o600);
42
- } catch (error) {
43
- if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
44
- throw error;
45
- }
46
- try {
47
- fs.ftruncateSync(this.fd, 0);
48
- fs.writeSync(this.fd, String(process.pid));
49
- fs.fsyncSync(this.fd);
50
- } catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
51
- const fd = this.fd;
52
- this.fd = null;
53
- try {
54
- fs.closeSync(fd);
55
- } catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
56
- }
57
- throw error;
42
+ // FAA-293: a folder swapped under service.lock (a data import) leaves this copy holding a file that is no longer
43
+ // at the path while another copy holds the one that is. Re-open a bounded number of times, then fail clearly.
44
+ for (let attempt = 0; attempt < InstanceLock.INODE_RETRIES; attempt += 1) {
45
+ if (this.acquireDarwinOnce()) return this;
58
46
  }
59
- return this;
47
+ throw new ServiceError(`${this.path} kept moving while it was locked (a folder swapped under the data folder?): no copy holds a file that is no longer at its path; start again once the folder is settled.`);
60
48
  }
61
49
  // Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
62
50
  // with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
@@ -90,6 +78,52 @@ export class InstanceLock {
90
78
  return this;
91
79
  }
92
80
 
81
+ lockedFileIsAtPath(fd) {
82
+ let here;
83
+ try {
84
+ here = fs.statSync(this.path);
85
+ } catch (error) {
86
+ if (error.code === 'ENOENT') return false;
87
+ throw error;
88
+ }
89
+ const held = fs.fstatSync(fd);
90
+ return here.dev === held.dev && here.ino === held.ino;
91
+ }
92
+
93
+ acquireDarwinOnce() {
94
+ const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
95
+ try {
96
+ this.fd = fs.openSync(this.path, flags, 0o600);
97
+ } catch (error) {
98
+ if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
99
+ throw error;
100
+ }
101
+ if (!this.lockedFileIsAtPath(this.fd)) { // another copy may hold the file at the path: re-open that one
102
+ fs.closeSync(this.fd);
103
+ this.fd = null;
104
+ return false;
105
+ }
106
+ try {
107
+ fs.ftruncateSync(this.fd, 0);
108
+ fs.writeSync(this.fd, String(process.pid));
109
+ fs.fsyncSync(this.fd);
110
+ } catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
111
+ const fd = this.fd;
112
+ this.fd = null;
113
+ try {
114
+ fs.closeSync(fd);
115
+ } catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
116
+ }
117
+ throw error;
118
+ }
119
+ if (!this.lockedFileIsAtPath(this.fd)) { // swapped while the pid was written: this copy holds nothing real
120
+ fs.closeSync(this.fd);
121
+ this.fd = null;
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+
93
127
  release() {
94
128
  if (this.fd !== null) fs.closeSync(this.fd);
95
129
  if (this.server) this.server.close();
@@ -277,6 +277,9 @@ export class Client {
277
277
  }
278
278
  attempts.push({ model: current, status: result.status, outcome: result.outcome });
279
279
  if (result.answer) return { ...result.answer, attempts };
280
+ if (result.outcome === 'foreign-user') { // FAA-257: the fallback would go to the same refused listener
281
+ throw new OpenRouterError('The base URL\'s port is held by another OS account\'s process, or its owner cannot be told; nothing was sent (DEC-0154 HA-2)', { kind: 'foreign-user', attempts });
282
+ }
280
283
  if (Object.values(STOP_AT_ONCE).includes(result.outcome)) {
281
284
  throw new OpenRouterError(`OpenRouter refused the key or the request (${result.status})`, { kind: result.outcome, attempts });
282
285
  }
@@ -299,6 +302,7 @@ export class Client {
299
302
  // may have been billed (a cut connection looks like a refused one from here): counted, never as $0
300
303
  this.ledger.record(this.unmeasured(asked, taskRunId, worst));
301
304
  }
305
+ if (err.kind === 'foreign-user') return { outcome: 'foreign-user', status: err.status }; // nothing sent, nothing billed (FAA-257)
302
306
  return { outcome: ['connection', 'timeout'].includes(err.kind) ? 'retry' : 'refused', status: err.status };
303
307
  }
304
308
  const { status } = response;
@@ -45,7 +45,7 @@ import http from 'node:http';
45
45
  import net from 'node:net';
46
46
  import os from 'node:os';
47
47
 
48
- export const PEER_OWNER_VERSION = '1.1.0+kit.3'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
48
+ export const PEER_OWNER_VERSION = '1.1.0+kit.4'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
49
49
  export const THIS_ACCOUNT = 'this-account';
50
50
  export const ANOTHER_ACCOUNT = 'another-account';
51
51
  export const SYSTEM = 'system';
@@ -154,6 +154,10 @@ export function parseNetstat(text) {
154
154
  const at = names.indexOf('process:pid');
155
155
  const leading = at - names.slice(0, at).filter((w) => w === 'Address').length;
156
156
  const trailing = names.length - at - 1;
157
+ // The caller's own connection is in any table it can see: a header and no tcp row is the restricted table (FAA-273).
158
+ if (!lines.slice(header + 1).some((line) => (line.split(/\s+/).filter(Boolean)[0] ?? '').startsWith('tcp'))) {
159
+ throw new TableUnreadable(`${TABLE_RESTRICTED}: netstat printed only its header to this process`);
160
+ }
157
161
  const rows = [];
158
162
  for (const line of lines.slice(header + 1)) {
159
163
  const tok = line.split(/\s+/).filter(Boolean);
@@ -534,7 +538,9 @@ export async function pidListens(pid, port, { run = runProgram, platform = proce
534
538
  // Rejects with ListenerRefused (the socket destroyed, nothing written) or with the connection's own error.
535
539
  export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, expectPid = null, vouchPid = null, ...ask } = {}) {
536
540
  return new Promise((resolveSocket, reject) => {
537
- const socket = net.connect({ host, port, noDelay: true }); // as a plain http.Agent's socket
541
+ // autoSelectFamily: Node 18 connects to the first address only, and localhost resolves to ::1 first on Linux, so a
542
+ // listener on 127.0.0.1 was never reached; Python tries every address, as Node 20 and later do by default.
543
+ const socket = net.connect({ host, port, noDelay: true, autoSelectFamily: true }); // as a plain http.Agent's socket
538
544
  const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), timeoutMs);
539
545
  socket.once('error', (err) => { clearTimeout(timer); reject(err); });
540
546
  socket.once('connect', async () => {
@@ -66,7 +66,10 @@ export function rawValues(query) {
66
66
  if (piece === '') continue;
67
67
  const at = piece.indexOf('=');
68
68
  const name = [...new URLSearchParams(at < 0 ? piece : piece.slice(0, at)).keys()][0] ?? '';
69
- values.set(name, [...(values.get(name) ?? []), at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1))]);
69
+ const value = at < 0 ? '' : decodeKeepingPlus(piece.slice(at + 1));
70
+ const list = values.get(name);
71
+ if (list) list.push(value); // appended in place: copying the list per piece made a long query quadratic (FAA-245)
72
+ else values.set(name, [value]);
70
73
  }
71
74
  return values;
72
75
  }
@@ -5,6 +5,7 @@
5
5
  export {
6
6
  PROTOCOL,
7
7
  EXTENSION_KEY,
8
+ PRIMARY_CAPABILITY,
8
9
  SUPERVISOR_ENV,
9
10
  DRAIN_SECONDS,
10
11
  ServiceError,
@@ -42,6 +43,10 @@ export {
42
43
  } from './fabric-protocols.mjs';
43
44
  export { ACTIONS_EXTENSION_KEY, actionDecision, actionRules, actionsExtension, actionsExtensionRules, actionsView, decisionShape } from './fabric-actions.mjs';
44
45
  export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
46
+ export { // the call queue and the load report (DEC-0104, FAA-289)
47
+ DEFAULT_RETRY_AFTER_SECONDS, LOAD_EXTENSION_KEY, LOAD_REPORT_TYPE, QUEUE_FULL_MESSAGE, QUEUE_LENGTH_MAX, admitJob, jobCallerKey, jobQueueRules,
48
+ loadExtension, loadExtensionRules, loadReportProblems, loadReportRules, loadView, nextQueuedJob, queueStatusMessage, readQueuePosition,
49
+ } from './fabric-load.mjs';
45
50
  export {
46
51
  EXIT_ALREADY_RUNNING,
47
52
  LOCK_WAIT_SUPERVISED_SECONDS,
@@ -117,7 +122,9 @@ export {
117
122
  fabricButtonPresence,
118
123
  placementOf,
119
124
  remoteOriginProblems,
125
+ portListenerRefusal,
120
126
  portOf,
127
+ portOwner,
121
128
  NO_SOURCE_REPOSITORY,
122
129
  namesRepository,
123
130
  sourceRefusal,
@@ -179,6 +186,7 @@ export {
179
186
  launchdPlist,
180
187
  SYSTEM_DAEMONS,
181
188
  launchdDomain,
189
+ wellKnownOf,
182
190
  jobWellKnownOf,
183
191
  ForeignUserAnswer,
184
192
  launchdLoaded,
@@ -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. */
@@ -490,8 +519,20 @@ export class ForeignUserAnswer {
490
519
  // The well-known answer at a loopback origin, read only on a connection whose server end is this OS account's process
491
520
  // (DEC-0154 HA-2): null when nothing answers or the answer is not JSON, a ForeignUserAnswer when the listener is another
492
521
  // account's or its owner cannot be told.
493
- export async function wellKnownOf(origin, { vouchPid = null } = {}) {
494
- const m = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/.exec(origin);
522
+ // Every loopback spelling (127.0.0.1, another 127/8 address, localhost, [::1]) goes on a checked connection, never the
523
+ // unchecked way (FAA-257): the host, without brackets, and the port, or null.
524
+ function loopbackOrigin(origin) {
525
+ let url;
526
+ try { url = new URL(String(origin)); } catch { return null; }
527
+ const host = url.hostname.replace(/^\[|\]$/g, '');
528
+ const loop = host === 'localhost' || host === '::1' || (net.isIPv4(host) && host.startsWith('127.'));
529
+ if (url.protocol !== 'http:' || !loop) return null;
530
+ // new URL drops a default port: http://127.0.0.1:80 has port '', read as 80 as Python's urlsplit reads it (0.79.0 seam)
531
+ return { host, port: Number(url.port || 80) };
532
+ }
533
+
534
+ export async function wellKnownOf(origin, { vouchPid = null, ask = undefined } = {}) {
535
+ const m = loopbackOrigin(origin);
495
536
  if (!m) {
496
537
  try {
497
538
  const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
@@ -502,14 +543,14 @@ export async function wellKnownOf(origin, { vouchPid = null } = {}) {
502
543
  }
503
544
  // A CheckedAgent without keep-alive: the request goes on the very socket it checked, and the socket closes after the
504
545
  // answer, so a poll leaves no connection open behind it.
505
- return loopbackWellKnown(Number(m[1]), new CheckedAgent({ keepAlive: false, vouchPid }));
546
+ return loopbackWellKnown(m.port, new CheckedAgent({ keepAlive: false, vouchPid, ...(ask ? { ask } : {}) }), m.host);
506
547
  }
507
548
 
508
549
  // The well-known answer at 127.0.0.1:<port> over `agent`, whose connections check their server end: null when nothing
509
550
  // answers or the answer is not JSON, a ForeignUserAnswer when the agent refused the listener.
510
- function loopbackWellKnown(port, agent) {
551
+ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
511
552
  return new Promise((resolve) => {
512
- const req = http.request({ host: '127.0.0.1', port, path: '/.well-known/fabric-service', agent,
553
+ const req = http.request({ host, port, path: '/.well-known/fabric-service', agent,
513
554
  timeout: 2000 }, (res) => {
514
555
  const chunks = [];
515
556
  res.on('data', (c) => chunks.push(c));
@@ -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
  }
@@ -16,6 +16,12 @@ PROTOCOL = "fabric-service/0.1"
16
16
  EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
17
17
 
18
18
 
19
+ # DEC-0140 PR-1 (contract project-comms.md, interop.md's C3.1 amendment): the capability of a Project's primary
20
+ # responder, a session's. No service serves it as a tool or declares it (surfaces.mcp.capabilities, an action's tool),
21
+ # and the board kit never enrolls a service's responder for it (FAA-244).
22
+ PRIMARY_CAPABILITY = "project.work"
23
+
24
+
19
25
  # Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
20
26
  # contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
21
27
  # off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
@@ -52,6 +52,9 @@ class BoardSession:
52
52
  timeout=timeout if timeout is not None and timeout > 0 else self.timeout,
53
53
  method=method, protocol_version=self.protocol_version if self.mcp_session else None)
54
54
  except _hub.HubError as exc:
55
+ if exc.code == "foreign-user": # FAA-252: another OS account's process holds the port; nothing was sent
56
+ raise BoardError("foreign-user", "The hub's port is held by another OS account's process; nothing was sent.",
57
+ True) from exc
55
58
  raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
56
59
  "the same idempotency key.", True) from exc
57
60
 
@@ -148,6 +148,9 @@ class Responder(SlotKeeper):
148
148
  caps = list(capabilities)
149
149
  if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
150
150
  raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
151
+ if _fs.PRIMARY_CAPABILITY in caps: # FAA-244: DEC-0140 PR-1, a session's slot, never a service's
152
+ raise BoardError("invalid_arguments", "%s is a Project's primary responder's, a session's: a service answers "
153
+ "the capabilities it declares (DEC-0140 PR-1)." % _fs.PRIMARY_CAPABILITY)
151
154
  self.hub, self.project = hub, _hub._project(project_id)
152
155
  #: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
153
156
  self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
@@ -165,6 +168,7 @@ class Responder(SlotKeeper):
165
168
  self.state = "idle"
166
169
  self.wait_until = 0.0
167
170
  self.stopping = False
171
+ self.shutting_down, self.draining = threading.Event(), None # shutdown began; it found work to drain (FAA-186)
168
172
  self.claim_keys: Dict[str, str] = {}
169
173
  self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
170
174
  self._said: Dict[str, str] = {}
@@ -258,7 +262,7 @@ class Responder(SlotKeeper):
258
262
  status = self._status()
259
263
  if missing:
260
264
  self._emit("board.access.needed", "warning", "The operator has not let this service answer %s on this Project; ask in "
261
- "Fabric (Agents & access)." % ", ".join(missing), notify=True, episode=",".join(missing),
265
+ "Fabric (Settings → Agent access)." % ", ".join(missing), notify=True, episode=",".join(missing),
262
266
  capabilities=missing)
263
267
  self.slots = {}
264
268
  self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
@@ -430,7 +434,8 @@ class Responder(SlotKeeper):
430
434
  try:
431
435
  self._receipt("com.reply", self._call("com.reply", dict(self._reply_extra(entry), fence=self._fence(old, probe["key"]))))
432
436
  except BoardError as exc:
433
- if exc.code in ("unreachable", "session-ended", "authority_changed", "not_authorized", "not_available", "capacity_exceeded"):
437
+ if exc.code in ("unreachable", "foreign-user", "session-ended", "authority_changed", "not_authorized", "not_available",
438
+ "capacity_exceeded"): # foreign-user: nothing was sent now, so nothing is known (0.79.0 seam)
434
439
  raise
435
440
  else:
436
441
  entry["replied"] = True
@@ -549,7 +554,7 @@ class Responder(SlotKeeper):
549
554
  self.state = "not-authorized"
550
555
  self._drop_session()
551
556
  self._emit("board.access.needed", "warning", "This service may not answer requests for this Project: the operator "
552
- "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
557
+ "has to allow it in Fabric (Settings → Agent access). Nothing is claimed until then.", notify=True,
553
558
  episode="access", reason=code)
554
559
  elif code == "held":
555
560
  self.state = "paused"
@@ -564,9 +569,13 @@ class Responder(SlotKeeper):
564
569
  else:
565
570
  self.state = "down"
566
571
  # In recovery with no slot held a refusal reconnects (seam 6); with some held they are served (seam 7).
567
- if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol")) and not keep:
572
+ if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol",
573
+ "foreign-user")) and not keep:
568
574
  self._drop_session()
569
- self._emit("board.down", "warning", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
575
+ # FAA-252: a port another OS account holds is said as such; nothing was sent there
576
+ self._emit("board.down", "warning", "Fabric's board port is held by another OS account's process; nothing was sent, "
577
+ "and the responder tries again with a growing pause." if code == "foreign-user" else
578
+ "Fabric's board cannot be reached; the responder tries again with a growing pause.",
570
579
  episode="down", reason=code)
571
580
  return wait
572
581
 
@@ -974,8 +983,8 @@ class Responder(SlotKeeper):
974
983
  def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
975
984
  """Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
976
985
  (its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
977
- self.stopping = True
978
- 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)
979
988
  if work is not None and not work.done.wait(drain):
980
989
  work.ctx.stopped.set()
981
990
  if work is not None and work.done.is_set() and self.session is not None:
@@ -20,7 +20,7 @@ HOLD_RETRY_MAX_SECONDS = 900.0
20
20
  #: Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault.
21
21
  SESSION_CODES = frozenset(["unreachable", "session-ended", "not_authorized", "authority_changed", "credential", "no-epoch",
22
22
  "schema-unsupported", "checkpoint", "receipt-shape", "idempotency_conflict", "not-running",
23
- "not-a-hub", "hub-unavailable", "protocol", "not_available"])
23
+ "not-a-hub", "hub-unavailable", "protocol", "not_available", "foreign-user"])
24
24
 
25
25
  _whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
26
26
 
@@ -78,7 +78,7 @@ class SlotKeeper:
78
78
  if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
79
79
  rest = {"project": self.project, "generation": generation}
80
80
  except BoardError as exc:
81
- if exc.code in ("unreachable", "session-ended"):
81
+ if exc.code in ("unreachable", "session-ended", "foreign-user"): # foreign-user: the earlier ask is unknown
82
82
  raise
83
83
  if exc.code == "idempotency_window_expired":
84
84
  rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
@@ -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]