@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.
- package/CHANGELOG.md +256 -0
- package/README.md +14 -7
- package/bin/fabric-agent-adapter.js +26 -4
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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();
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs
CHANGED
|
@@ -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;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 () => {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -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,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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(
|
|
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
|
|
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));
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py
CHANGED
|
@@ -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
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -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 (
|
|
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",
|
|
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 (
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py
CHANGED
|
@@ -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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py
CHANGED
|
@@ -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
|
|
202
|
-
# valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
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
|
-
|
|
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]
|