@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.83.1
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 +200 -3
- package/README.md +11 -6
- package/SECURITY.md +8 -5
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
- 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 +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
- 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 +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Which OS account owns each end of a loopback TCP connection (DEC-0154 HA-2, FAA-226, layer L0); the twin of
|
|
2
|
-
// fabric_peer_owner.py, which is Project Observatory's peer_owner
|
|
3
|
-
//
|
|
2
|
+
// fabric_peer_owner.py, which is Project Observatory's peer_owner vendored by commit (its 1.3.1 line). Node 18+, no
|
|
3
|
+
// dependency; the one kit module it may load is fabric-socket-proof.mjs beside it (DEC-0155 RT-2, FAA-292): on a
|
|
4
|
+
// restricted macOS table a server end or listener one of this account's own processes holds is this account's, and
|
|
5
|
+
// without that file this one answers as before.
|
|
4
6
|
//
|
|
5
7
|
// WHY. Loopback is one per computer, not one per OS account: every account's processes reach every listener on
|
|
6
8
|
// 127.0.0.1, so a loopback socket proves nothing about who is on the other end. A client about to send a credential
|
|
@@ -40,12 +42,14 @@
|
|
|
40
42
|
// privilege. The parsers are pure functions over captured text (test/fixtures/peer-owner/).
|
|
41
43
|
|
|
42
44
|
import { execFile } from 'node:child_process';
|
|
45
|
+
import { realpathSync } from 'node:fs';
|
|
43
46
|
import { open } from 'node:fs/promises';
|
|
44
47
|
import http from 'node:http';
|
|
45
48
|
import net from 'node:net';
|
|
46
49
|
import os from 'node:os';
|
|
50
|
+
import { fileURLToPath } from 'node:url';
|
|
47
51
|
|
|
48
|
-
export const PEER_OWNER_VERSION = '1.
|
|
52
|
+
export const PEER_OWNER_VERSION = '1.3.1+kit.7'; // the Python twin's, Observatory's 1.3.1 line with the kit's differences
|
|
49
53
|
export const THIS_ACCOUNT = 'this-account';
|
|
50
54
|
export const ANOTHER_ACCOUNT = 'another-account';
|
|
51
55
|
export const SYSTEM = 'system';
|
|
@@ -62,6 +66,8 @@ export const LSOF = '/usr/sbin/lsof';
|
|
|
62
66
|
// The reason a socket table hides other processes' sockets from this one (FAA-267): macOS 27.2 gives an ad-hoc-signed
|
|
63
67
|
// runtime an empty netstat.
|
|
64
68
|
export const TABLE_RESTRICTED = 'table-restricted';
|
|
69
|
+
// A macOS table that lists sockets but not the reader's own connection (DEC-0155 RT-1, FAA-312).
|
|
70
|
+
export const NO_ROW_RESTRICTED = `${TABLE_RESTRICTED}: no row for this connection`;
|
|
65
71
|
export const SERVER_NOT_OWN = 'server-not-own';
|
|
66
72
|
export const SERVER_UNVERIFIED = 'server-unverified';
|
|
67
73
|
export const SERVER_NOT_THIS_WORKSPACE = 'server-not-this-workspace';
|
|
@@ -290,10 +296,10 @@ export function runProgram(file, args, timeout = TIMEOUT_MS) {
|
|
|
290
296
|
});
|
|
291
297
|
}
|
|
292
298
|
|
|
293
|
-
export function method() {
|
|
294
|
-
if (
|
|
295
|
-
if (
|
|
296
|
-
if (
|
|
299
|
+
export function method(platform = process.platform) {
|
|
300
|
+
if (platform === 'darwin') return 'netstat';
|
|
301
|
+
if (platform === 'linux') return 'proc';
|
|
302
|
+
if (platform === 'win32') return 'netstat-windows';
|
|
297
303
|
return 'none';
|
|
298
304
|
}
|
|
299
305
|
|
|
@@ -319,9 +325,18 @@ async function readProc(name) {
|
|
|
319
325
|
return Buffer.concat(chunks);
|
|
320
326
|
}
|
|
321
327
|
|
|
322
|
-
export async function readTable({ run = runProgram } = {}) {
|
|
323
|
-
const how = method();
|
|
324
|
-
if (how === 'netstat')
|
|
328
|
+
export async function readTable({ run = runProgram, platform = process.platform } = {}) {
|
|
329
|
+
const how = method(platform);
|
|
330
|
+
if (how === 'netstat') {
|
|
331
|
+
let text;
|
|
332
|
+
try {
|
|
333
|
+
text = await run(NETSTAT, ['-anv', '-p', 'tcp']);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
// A netstat that fails, exits non-zero or times out is a restricted table (DEC-0155 RT-1, FAA-312).
|
|
336
|
+
throw new TableUnreadable(`${TABLE_RESTRICTED}: ${err instanceof TableUnreadable ? err.message : `netstat: ${err?.name ?? 'error'}`}`);
|
|
337
|
+
}
|
|
338
|
+
return { rows: parseNetstat(text), how };
|
|
339
|
+
}
|
|
325
340
|
if (how === 'proc') {
|
|
326
341
|
const rows = [];
|
|
327
342
|
for (const [name, family] of [['/proc/net/tcp', 4], ['/proc/net/tcp6', 6]]) {
|
|
@@ -387,6 +402,21 @@ export async function resolve(row, how, { run = runProgram, ownUid: mine, ownSid
|
|
|
387
402
|
|
|
388
403
|
// -- the questions ----------------------------------------------------------------------------------------------------
|
|
389
404
|
|
|
405
|
+
// DEC-0155 RT-1 (FAA-312): a macOS table holding no row of another uid (sysctl, the Python twin's, which carries uids)
|
|
406
|
+
// or of another process (netstat, which carries pids) is restricted, and a missing row proves nothing. Never on Linux
|
|
407
|
+
// or Windows, which carry the owner on each row.
|
|
408
|
+
export function restricted(rows, how, { ownUid: mine, ownPid } = {}) {
|
|
409
|
+
if (how === 'sysctl') {
|
|
410
|
+
const own = mine ?? ownUid();
|
|
411
|
+
return !rows.some((r) => r.uid !== null && r.uid !== undefined && r.uid !== own);
|
|
412
|
+
}
|
|
413
|
+
if (how === 'netstat') {
|
|
414
|
+
const own = ownPid ?? process.pid;
|
|
415
|
+
return !rows.some((r) => r.pid !== null && r.pid !== undefined && r.pid !== own);
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
390
420
|
// The live connected rows with exactly this local and remote address: one, or the caller cannot tell.
|
|
391
421
|
// A row printed twice is one socket: Linux resumes a /proc read that sockets came and went under by position, and can
|
|
392
422
|
// print the same row again (0.74.0 seam reading 6); rows equal in every field count once.
|
|
@@ -414,7 +444,8 @@ async function ownerOf(local, remote, opts = {}, server = false) {
|
|
|
414
444
|
// A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
|
|
415
445
|
if (table.how !== 'proc' || found.length === 1) break;
|
|
416
446
|
}
|
|
417
|
-
|
|
447
|
+
// A macOS table without the reader's own connection is restricted (DEC-0155 RT-1, FAA-312).
|
|
448
|
+
if (found.length === 0) return owner(UNKNOWN, { method: table.how, reason: ['sysctl', 'netstat'].includes(table.how) ? NO_ROW_RESTRICTED : 'no-row' });
|
|
418
449
|
if (found.length > 1) return owner(UNKNOWN, { method: table.how, reason: 'several-rows' });
|
|
419
450
|
if (table.how === 'proc' && server && found[0].uid === null) {
|
|
420
451
|
// Linux: a connection not yet accept()ed is printed with uid 0 and inode 0; it belongs to the listener on its local
|
|
@@ -449,10 +480,36 @@ async function oneOwner(found, how, opts, noneReason = null) {
|
|
|
449
480
|
return owner(UNKNOWN, { method: how, reason: 'several-rows' });
|
|
450
481
|
}
|
|
451
482
|
|
|
483
|
+
// fabric-socket-proof.mjs beside this file, loaded once (DEC-0155 RT-2, FAA-292); null when it is not there.
|
|
484
|
+
let socketProof;
|
|
485
|
+
|
|
486
|
+
// `fallback`, unless it is a restricted macOS table's unknown and fabric-socket-proof finds the socket (`accepted`) or
|
|
487
|
+
// the 127.0.0.1 listener (`ownListener`, FAA-272) in this account's own processes; else it stands. A test that injects
|
|
488
|
+
// a table (`read`, `run`) reaches this host's processes only through the `socketProof` it names.
|
|
489
|
+
async function proved(ask, args, fallback, opts = {}) {
|
|
490
|
+
if (fallback.kind !== UNKNOWN || !String(fallback.reason ?? '').startsWith(TABLE_RESTRICTED)) return fallback;
|
|
491
|
+
if ((opts.platform ?? process.platform) !== 'darwin') return fallback;
|
|
492
|
+
let module = opts.socketProof;
|
|
493
|
+
if (module === undefined) {
|
|
494
|
+
if (opts.read || opts.run) return fallback;
|
|
495
|
+
if (socketProof === undefined) socketProof = await import('./fabric-socket-proof.mjs').catch(() => null);
|
|
496
|
+
module = socketProof;
|
|
497
|
+
}
|
|
498
|
+
let p = null;
|
|
499
|
+
try {
|
|
500
|
+
p = module ? await module[ask](...args) : null;
|
|
501
|
+
} catch {
|
|
502
|
+
p = null; // a proof that fails proves nothing; the table's answer stands (fail closed)
|
|
503
|
+
}
|
|
504
|
+
return p?.proved ? owner(THIS_ACCOUNT, { uid: opts.ownUid ?? ownUid(), pid: p.pid, method: p.method, reason: p.reason }) : fallback;
|
|
505
|
+
}
|
|
506
|
+
|
|
452
507
|
// The owner of the CLIENT socket of the connection between `server` and `client` ([host, port] each), as a server sees
|
|
453
508
|
// them: the row whose local address is the client's and whose remote address is the server's.
|
|
454
|
-
export function peerAt(server, client, opts) {
|
|
455
|
-
|
|
509
|
+
export async function peerAt(server, client, opts) {
|
|
510
|
+
const c = address(client[0], client[1]);
|
|
511
|
+
const s = address(server[0], server[1]);
|
|
512
|
+
return proved('accepted', [c, s, { wait: false }], await ownerOf(c, s, opts), opts);
|
|
456
513
|
}
|
|
457
514
|
|
|
458
515
|
// For a server's accepted socket: who connected. Reads nothing from the socket.
|
|
@@ -465,9 +522,11 @@ export function peer(socket, opts) {
|
|
|
465
522
|
// address is this socket's own, the server process's accepted socket, so a listener swapped after listen cannot pass
|
|
466
523
|
// for the one that answers. On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
|
|
467
524
|
// answers (the contract's CT-254, HA-2's Linux source).
|
|
468
|
-
export function serverEnd(socket, opts) {
|
|
469
|
-
if (!socket.remoteAddress || !socket.localAddress) return
|
|
470
|
-
|
|
525
|
+
export async function serverEnd(socket, opts) {
|
|
526
|
+
if (!socket.remoteAddress || !socket.localAddress) return owner(UNKNOWN, { reason: 'socket: not connected' });
|
|
527
|
+
const s = address(socket.remoteAddress, socket.remotePort);
|
|
528
|
+
const own = address(socket.localAddress, socket.localPort);
|
|
529
|
+
return proved('accepted', [s, own, { wait: true }], await ownerOf(s, own, opts, true), opts);
|
|
471
530
|
}
|
|
472
531
|
|
|
473
532
|
const LISTEN_ORDER = ['127.0.0.1', '*', '::1'];
|
|
@@ -491,23 +550,28 @@ export async function listener(port, opts = {}) {
|
|
|
491
550
|
const at = now();
|
|
492
551
|
const hit = injected ? undefined : listeners.get(port);
|
|
493
552
|
if (hit && at - hit[0] < LISTENER_TTL_MS) return hit[1];
|
|
494
|
-
let found;
|
|
495
|
-
let how;
|
|
496
553
|
let table;
|
|
497
554
|
try {
|
|
498
555
|
table = await (read ?? readTable)(opts);
|
|
499
|
-
how = table.how;
|
|
500
|
-
found = pickListener(table.rows, port);
|
|
501
556
|
} catch (err) {
|
|
502
|
-
const who = owner(UNKNOWN, { method: method(), reason: err instanceof TableUnreadable ? err.message : `table: ${err.name}` });
|
|
557
|
+
const who = await proved('ownListener', [port], owner(UNKNOWN, { method: method(), reason: err instanceof TableUnreadable ? err.message : `table: ${err.name}` }), opts);
|
|
503
558
|
if (!injected) listeners.set(port, [at, who]);
|
|
504
559
|
return who;
|
|
505
560
|
}
|
|
506
|
-
const who = await
|
|
561
|
+
const who = await proved('ownListener', [port], restricted(table.rows, table.how, opts)
|
|
562
|
+
? await restrictedListener(table.rows, port, table.how, opts) : await oneOwner(pickListener(table.rows, port), table.how, opts), opts);
|
|
507
563
|
if (!injected) listeners.set(port, [at, who]);
|
|
508
564
|
return who;
|
|
509
565
|
}
|
|
510
566
|
|
|
567
|
+
// DEC-0155 RT-4 (FAA-312): under a restricted table only this account's own bind on exactly 127.0.0.1 is its own; its
|
|
568
|
+
// wildcard or ::1 listener, root's, and a port with no row are an owner that cannot be told.
|
|
569
|
+
async function restrictedListener(rows, port, how, opts) {
|
|
570
|
+
const who = await oneOwner(rows.filter((r) => r.listening && r.local[0] === '127.0.0.1' && r.local[1] === port), how, opts);
|
|
571
|
+
if (who.kind === THIS_ACCOUNT) return who;
|
|
572
|
+
return owner(UNKNOWN, { method: how, reason: `${TABLE_RESTRICTED}: no listener of this account on exactly 127.0.0.1:${port}` });
|
|
573
|
+
}
|
|
574
|
+
|
|
511
575
|
// [code, why] when a client must not send on a connection whose server end is `who`, else null.
|
|
512
576
|
export function refusal(who, port, expectPid = null) {
|
|
513
577
|
if (who.kind === ANOTHER_ACCOUNT || who.kind === SYSTEM) return [SERVER_NOT_OWN, `the server on port ${port} belongs to ${who.kind}`];
|
|
@@ -518,16 +582,25 @@ export function refusal(who, port, expectPid = null) {
|
|
|
518
582
|
return null;
|
|
519
583
|
}
|
|
520
584
|
|
|
521
|
-
// macOS, when the socket table is restricted (FAA-267): whether process `pid
|
|
522
|
-
//
|
|
523
|
-
// `
|
|
524
|
-
|
|
585
|
+
// macOS, when the socket table is restricted (FAA-267, DEC-0155 RT-3): whether process `pid` listens on exactly
|
|
586
|
+
// 127.0.0.1:<port> (never the IPv4 wildcard, which lsof prints as it prints an IPv6-only one, nor ::1; FAA-312).
|
|
587
|
+
// `lsof -p` asks the process itself, which the restriction does not hide. Only a caller that started `pid` itself (a
|
|
588
|
+
// launchd job it installed) may vouch with it, and never a set-uid one for this account (its real and effective uid
|
|
589
|
+
// differ, DEC-0155). `owner`: the uid `pid` must run as, this account's by default, or null when the caller checks it
|
|
590
|
+
// itself (a LaunchDaemon's UserName, FAA-286); anything else is a RangeError.
|
|
591
|
+
export async function pidListens(pid, port, { run = runProgram, platform = process.platform, ownUid: mine, ownEuid, owner: want = THIS_ACCOUNT } = {}) {
|
|
592
|
+
if (want !== null && want !== THIS_ACCOUNT && !(Number.isInteger(want) && want >= 0)) throw new RangeError('owner is this-account, a uid or null');
|
|
525
593
|
if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
594
|
+
const real = mine ?? ownUid();
|
|
595
|
+
const effective = ownEuid ?? (typeof process.geteuid === 'function' ? process.geteuid() : real);
|
|
596
|
+
if (want === THIS_ACCOUNT && effective !== real) return false;
|
|
526
597
|
try {
|
|
527
|
-
|
|
528
|
-
|
|
598
|
+
if (want !== null) {
|
|
599
|
+
const uid = parsePsUid(await run(PS, ['-o', 'uid=', '-p', String(pid)]));
|
|
600
|
+
if (uid === null || uid !== (want === THIS_ACCOUNT ? real : want)) return false;
|
|
601
|
+
}
|
|
529
602
|
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
530
|
-
return parseLsofListeners(out).some(([host, p]) =>
|
|
603
|
+
return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port);
|
|
531
604
|
} catch {
|
|
532
605
|
return false;
|
|
533
606
|
}
|
|
@@ -553,8 +626,9 @@ export function checkedSocket(port, { host = '127.0.0.1', timeoutMs = 10000, exp
|
|
|
553
626
|
// A reader that throws (an injected one; the readers themselves answer unknown) fails closed.
|
|
554
627
|
who = owner(UNKNOWN, { method: method(), reason: `check: ${err?.name ?? 'Error'}` });
|
|
555
628
|
}
|
|
629
|
+
// The vouch proves a listener on exactly 127.0.0.1, so only a connection that reached it (FAA-273 item 12).
|
|
556
630
|
if (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED) && vouchPid
|
|
557
|
-
&& await pidListens(vouchPid, port, ask)) {
|
|
631
|
+
&& normHost(socket.remoteAddress) === '127.0.0.1' && await pidListens(vouchPid, port, ask)) {
|
|
558
632
|
// The table hides other processes (FAA-267); the process this caller started holds the listener.
|
|
559
633
|
who = owner(THIS_ACCOUNT, { uid: ask.ownUid ?? ownUid(), pid: vouchPid, method: 'lsof-pid' });
|
|
560
634
|
}
|
|
@@ -587,3 +661,32 @@ export class CheckedAgent extends http.Agent {
|
|
|
587
661
|
expectPid: this.expectPid, vouchPid: this.vouchPid, ...this.ask }).then((socket) => callback(null, socket), (err) => callback(err));
|
|
588
662
|
}
|
|
589
663
|
}
|
|
664
|
+
|
|
665
|
+
const USAGE = 'usage: fabric-peer-owner.mjs listener <port> [--json]\n';
|
|
666
|
+
|
|
667
|
+
// `listener <port> [--json]`: the kind of who listens on 127.0.0.1:<port>, which the dashboard link gate takes as
|
|
668
|
+
// listener_owner (FAA-315), or the whole owner as JSON; 2 on a usage error. `listener` and `write` are the tests'.
|
|
669
|
+
export async function main(argv, { listener: ask = listener, write = (text) => process.stdout.write(text) } = {}) {
|
|
670
|
+
const asJson = argv.length === 3 && argv[2] === '--json';
|
|
671
|
+
const port = Number(argv[1]);
|
|
672
|
+
if (argv.length !== 2 + Number(asJson) || argv[0] !== 'listener' || !/^[0-9]+$/.test(argv[1] ?? '') || port < 1 || port > 65535) {
|
|
673
|
+
process.stderr.write(USAGE);
|
|
674
|
+
return 2;
|
|
675
|
+
}
|
|
676
|
+
const who = await ask(port);
|
|
677
|
+
write(asJson ? `${JSON.stringify({ kind: who.kind, uid: who.uid, pid: who.pid, method: who.method, reason: who.reason })}\n` : `${who.kind}\n`);
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Whether node was started on this file, by real paths (a symlinked install folder, as check_dashboard_layout.mjs).
|
|
682
|
+
function startedDirectly() {
|
|
683
|
+
try {
|
|
684
|
+
return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
685
|
+
} catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (startedDirectly()) {
|
|
691
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => { process.stderr.write(`${err?.message ?? err}\n`); process.exitCode = 1; });
|
|
692
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -38,6 +38,31 @@ export function credentialName(name) {
|
|
|
38
38
|
|
|
39
39
|
/** A JWT or JWS anywhere in a value. */
|
|
40
40
|
export const JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/;
|
|
41
|
+
const isUrlBase64 = (code) => (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || (code >= 0x30 && code <= 0x39)
|
|
42
|
+
|| code === 0x2d || code === 0x5f;
|
|
43
|
+
const runEnd = (text, from) => { let end = from; while (end < text.length && isUrlBase64(text.charCodeAt(end))) end += 1; return end; };
|
|
44
|
+
/** The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's jwtSpans,
|
|
45
|
+
* FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one
|
|
46
|
+
* run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends. `first`
|
|
47
|
+
* stops at the first match. */
|
|
48
|
+
export function jwtSpans(text, first = false) {
|
|
49
|
+
const spans = [];
|
|
50
|
+
let from = 0;
|
|
51
|
+
for (let at = text.indexOf('eyJ', from); at >= 0; at = text.indexOf('eyJ', from)) {
|
|
52
|
+
const one = runEnd(text, at + 3);
|
|
53
|
+
from = one;
|
|
54
|
+
if (one - at - 3 < 2 || text[one] !== '.') continue;
|
|
55
|
+
const two = runEnd(text, one + 1);
|
|
56
|
+
if (two - one - 1 < 2 || text[two] !== '.') continue;
|
|
57
|
+
const three = runEnd(text, two + 1);
|
|
58
|
+
spans.push([at, three]);
|
|
59
|
+
if (first) break;
|
|
60
|
+
from = three;
|
|
61
|
+
}
|
|
62
|
+
return spans;
|
|
63
|
+
}
|
|
64
|
+
/** Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's hasJwt, FAA-313). */
|
|
65
|
+
export const hasJwt = (text) => jwtSpans(text, true).length > 0;
|
|
41
66
|
/** A value that says it is a credential (`Bearer ` at its start, in any case), or a provider key's shape with its prefix
|
|
42
67
|
* in its canonical case only, at the start of a value, after a character that is no ASCII letter or digit, or after an
|
|
43
68
|
* escape; an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more,
|
|
@@ -48,7 +73,7 @@ const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
|
48
73
|
export const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
|
|
49
74
|
|
|
50
75
|
/** Whether a value could be a credential: a JWT anywhere, a provider key or `Bearer …`, or a long secret-shaped run. */
|
|
51
|
-
export const secretValue = (value) =>
|
|
76
|
+
export const secretValue = (value) => hasJwt(value) || SECRET_VALUE.test(value) || longSecret(value);
|
|
52
77
|
|
|
53
78
|
export const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
|
|
54
79
|
const PATH_LIKE = /^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*:\/\/)/i;
|
|
@@ -58,7 +83,7 @@ const pathLike = (piece) =>
|
|
|
58
83
|
|
|
59
84
|
/** The same for a launch argument or a server's command and URL: a long run is looked for between path separators. */
|
|
60
85
|
export const secretArgument = (value) =>
|
|
61
|
-
|
|
86
|
+
hasJwt(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
|
|
62
87
|
value.split(/[\s=]/).some((piece) => (pathLike(piece) ? piece.split(/[\\/]/).some((part) => longSecret(part)) : longSecret(piece)));
|
|
63
88
|
|
|
64
89
|
const decodeKeepingPlus = (raw) => { try { return decodeURIComponent(raw); } catch { return raw; } };
|
|
@@ -99,12 +124,25 @@ const REPOSITORY_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
|
99
124
|
// boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
|
|
100
125
|
const REPOSITORY_KEYS = 'sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}';
|
|
101
126
|
const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${REPOSITORY_KEYS})`);
|
|
102
|
-
|
|
127
|
+
/** Whether JWT_SHAPE matches at a word's start: the text's start, or after a `.`, `_` or `-` (FAA-321), read in one
|
|
128
|
+
* pass as jwtSpans: only a run's first `eyJ` at a word's start is tried (fabric_secrets.jwt_at_a_word). */
|
|
129
|
+
export function jwtAtAWord(text) {
|
|
130
|
+
for (let at = text.indexOf('eyJ'); at >= 0;) {
|
|
131
|
+
if (at > 0 && !'._-'.includes(text[at - 1])) { at = text.indexOf('eyJ', at + 1); continue; }
|
|
132
|
+
const one = runEnd(text, at + 3);
|
|
133
|
+
if (one - at - 3 >= 2 && text[one] === '.') {
|
|
134
|
+
const two = runEnd(text, one + 1);
|
|
135
|
+
if (two - one - 1 >= 2 && text[two] === '.') return true;
|
|
136
|
+
}
|
|
137
|
+
at = text.indexOf('eyJ', one);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
103
141
|
const SPECIAL_QUERY = /^(?:https?|wss?|ftp|file):/i; // whose query also encodes `'`, as the URL standard does
|
|
104
142
|
|
|
105
143
|
/** Whether a path segment reads as a repository's name: lower case, or letters with no digit, with no provider key's
|
|
106
144
|
* or JWT's shape at its start or after a `.`, `_` or `-` (0.62.0 readings 9 and 10), so a letters-only key is a key. */
|
|
107
|
-
export const repositoryName = (piece) => REPOSITORY_NAME.test(piece) && !KEY_AT_A_WORD.test(piece) && !
|
|
145
|
+
export const repositoryName = (piece) => REPOSITORY_NAME.test(piece) && !KEY_AT_A_WORD.test(piece) && !jwtAtAWord(piece);
|
|
108
146
|
|
|
109
147
|
/** Every `%XX` escape of an ASCII character decoded, the rest as written (fabric_secrets.ascii_decoded, readings 11). */
|
|
110
148
|
export const asciiDecoded = (text) => text.replace(/%([0-7][0-9A-Fa-f])/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
@@ -122,7 +160,8 @@ export function repositoryPassword(value) {
|
|
|
122
160
|
|
|
123
161
|
// A repository's parameters, read as fabric_secrets.secret_url reads them, by hand: `new URL`
|
|
124
162
|
// refuses a host it cannot parse and a special scheme written without `//` (`https:host/r?access_token=...`), where
|
|
125
|
-
// the Python twin
|
|
163
|
+
// the Python twin's repository_credential reads them too (0.62.0 readings 8 and 9, FAA-200 (17)); its secret_url, like
|
|
164
|
+
// secretUrl, refuses such a URL since FAA-309.
|
|
126
165
|
function repositoryParts(head, tail) {
|
|
127
166
|
const text = tail.replace(/[\x00-\x20]+$/, '').replace(/[\t\n\r]/g, '');
|
|
128
167
|
const hash = text.indexOf('#');
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// This OS account's own TCP sockets, read from its own processes (layer L0; DEC-0155 RT-2, FAA-292; FAA-272); the twin
|
|
2
|
+
// of fabric_socket_proof.py. Node 18+, no dependency; nothing here imports another kit module.
|
|
3
|
+
//
|
|
4
|
+
// macOS 27.2 shows an ad-hoc-signed runtime (Homebrew's Node) only its own sockets, so fabric-peer-owner reads such a
|
|
5
|
+
// table as table-restricted and cannot tell who accepted a connection. The question can still be asked of this
|
|
6
|
+
// account's own processes, which the restriction leaves readable: whether one of them holds the very socket that
|
|
7
|
+
// accepted the connection (RT-2, the accepted-socket proof), or a listener bound exactly to 127.0.0.1:<port> (HA-2's
|
|
8
|
+
// macOS row, FAA-272). Another account's process is not listed, so a proof only ever says "this account's" or "not
|
|
9
|
+
// proved"; never another account's, the system's or nobody's.
|
|
10
|
+
//
|
|
11
|
+
// await accepted(server, client, { wait }) the socket at `server` connected to `client` is in one of this
|
|
12
|
+
// account's processes (`wait`: read again for ACCEPT_WAIT_MS until the
|
|
13
|
+
// server has called accept())
|
|
14
|
+
// await ownListener(port) a listener of this account on exactly 127.0.0.1:<port>
|
|
15
|
+
//
|
|
16
|
+
// Node has no ctypes, so the twin reads RT-2's other named source, `lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state>
|
|
17
|
+
// -F pun`, matching the exact address pair and the numeric uid, where the Python twin reads the same records through
|
|
18
|
+
// libproc (lsof only when libproc is unavailable). The twins agree on answers, not on the program they run (DEC-0155
|
|
19
|
+
// Compatibility); test/fixtures/peer-owner/socket-proof-cases.json holds both. The proof is "a process of this uid
|
|
20
|
+
// holds the descriptor", weaker than a table's so_uid: a socket passed with SCM_RIGHTS to a process of this uid reads
|
|
21
|
+
// as this account's, which a caller that knows the server's pid closes with expectPid. One socket several of this
|
|
22
|
+
// account's processes hold is proved with pid null, reason several-processes. A process whose real and effective uids
|
|
23
|
+
// differ is refused (setuid-context). Reasons: table-restricted (read, nothing of this account matches) or
|
|
24
|
+
// `table-restricted: <detail>` (the proof could not be read). Bounded by TIMEOUT_MS and MAX_OUTPUT_BYTES.
|
|
25
|
+
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import net from 'node:net';
|
|
28
|
+
|
|
29
|
+
export const TABLE_RESTRICTED = 'table-restricted';
|
|
30
|
+
export const LSOF_METHOD = 'lsof';
|
|
31
|
+
export const LSOF = '/usr/sbin/lsof';
|
|
32
|
+
export const TIMEOUT_MS = 2000;
|
|
33
|
+
export const ACCEPT_WAIT_MS = 500;
|
|
34
|
+
export const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
const proof = (proved, pid, method, reason) => Object.freeze({ proved, pid, method, reason });
|
|
37
|
+
const digits = (text) => /^[0-9]+$/.test(text);
|
|
38
|
+
|
|
39
|
+
// One spelling per address, as fabric-peer-owner's normHost (the same rule, kept here so this module stands alone): an
|
|
40
|
+
// IPv4-mapped IPv6 address is its IPv4 form, the unspecified address `*`, an IPv6 address RFC 5952's.
|
|
41
|
+
function host(text) {
|
|
42
|
+
let h = String(text ?? '').replace(/^\[(.*)\]$/, '$1');
|
|
43
|
+
if (h === '*' || h === '') return '*';
|
|
44
|
+
if (h === 'localhost') return '127.0.0.1';
|
|
45
|
+
h = h.split('%', 1)[0];
|
|
46
|
+
if (net.isIPv4(h)) return h === '0.0.0.0' ? '*' : h.split('.').map((x) => String(Number(x))).join('.');
|
|
47
|
+
if (!net.isIPv6(h)) return h;
|
|
48
|
+
const t = new URL(`http://[${h}]/`).hostname.slice(1, -1);
|
|
49
|
+
if (t === '::') return '*';
|
|
50
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(t);
|
|
51
|
+
if (!mapped) return t;
|
|
52
|
+
const hi = parseInt(mapped[1], 16);
|
|
53
|
+
const lo = parseInt(mapped[2], 16);
|
|
54
|
+
const v4 = [hi >> 8, hi & 255, lo >> 8, lo & 255].join('.');
|
|
55
|
+
return v4 === '0.0.0.0' ? '*' : v4;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Whether `new URL(...).hostname` names this computer's loopback: localhost or a name under .localhost, ::1, or an IPv4
|
|
59
|
+
// address in 127/8 (new URL has already read 127.1, 2130706433, 0x7f.1 and a trailing dot as 127.x.y.z); never an
|
|
60
|
+
// IPv4-mapped address, which new URL writes in hex. The one rule fabric-http's checkUrl and fabric-delegation's key set
|
|
61
|
+
// source read (FAA-187 (15)); the twin of fabric_socket_proof.loopback_host.
|
|
62
|
+
export function loopbackHost(hostname) {
|
|
63
|
+
const h = String(hostname ?? '').toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
64
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
|
|
65
|
+
return /^127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.test(h) && h.split('.').every((x) => Number(x) <= 255);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lsofAddress(text) {
|
|
69
|
+
const at = text.lastIndexOf(':');
|
|
70
|
+
if (at < 0) return null;
|
|
71
|
+
const port = text.slice(at + 1);
|
|
72
|
+
if (!digits(port) || Number(port) > 65535) return null;
|
|
73
|
+
return [host(text.slice(0, at)), Number(port)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// `lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`: the `n` lines of every process block whose `u` line is
|
|
77
|
+
// exactly `uid` (a block of another uid, or with no uid, is not read). A name with `->` is a connection, one without a
|
|
78
|
+
// listener. The same socket listed by several processes (one address pair) is one socket with pid null.
|
|
79
|
+
export function parseLsofSockets(text, uid) {
|
|
80
|
+
const found = new Map();
|
|
81
|
+
let pid = null;
|
|
82
|
+
let owner = null;
|
|
83
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
84
|
+
const tag = line.slice(0, 1);
|
|
85
|
+
const value = line.slice(1);
|
|
86
|
+
if (tag === 'p') {
|
|
87
|
+
pid = digits(value) ? Number(value) : null;
|
|
88
|
+
owner = null;
|
|
89
|
+
} else if (tag === 'u') {
|
|
90
|
+
owner = digits(value) ? Number(value) : null;
|
|
91
|
+
} else if (tag === 'n' && pid !== null && owner === uid) {
|
|
92
|
+
const arrow = value.indexOf('->');
|
|
93
|
+
const local = lsofAddress(arrow < 0 ? value : value.slice(0, arrow));
|
|
94
|
+
const remote = arrow < 0 ? ['*', 0] : lsofAddress(value.slice(arrow + 2));
|
|
95
|
+
if (!local || !remote) continue;
|
|
96
|
+
const key = JSON.stringify([local, remote, arrow < 0]);
|
|
97
|
+
if (!found.has(key)) found.set(key, { local, remote, listening: arrow < 0, pids: [] });
|
|
98
|
+
found.get(key).pids.push(pid);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...found.values()].map(({ local, remote, listening, pids }) => ({
|
|
102
|
+
local, remote, listening, pid: new Set(pids).size === 1 ? pids[0] : null,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const same = (a, b) => a[0] === b[0] && a[1] === b[1];
|
|
107
|
+
|
|
108
|
+
function proved(hits, how) {
|
|
109
|
+
if (hits.length === 0) return proof(false, null, how, TABLE_RESTRICTED);
|
|
110
|
+
const pids = new Set(hits.map((s) => s.pid));
|
|
111
|
+
if (pids.size === 1 && !pids.has(null)) return proof(true, hits[0].pid, how, null);
|
|
112
|
+
return proof(true, null, how, 'several-processes');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// RT-2 over this account's sockets: the one at `server` connected to `client`; two with that pair are several-rows.
|
|
116
|
+
export function acceptedIn(sockets, server, client, how) {
|
|
117
|
+
const hits = sockets.filter((s) => !s.listening && same(s.local, server) && same(s.remote, client));
|
|
118
|
+
if (hits.length > 1) return proof(false, null, how, 'several-rows');
|
|
119
|
+
return proved(hits, how);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// FAA-272 over this account's sockets: a listener bound exactly to 127.0.0.1:<port> (DEC-0155 RT-4: our wildcard or
|
|
123
|
+
// ::1 listener, and root's, cannot be told from a hidden one).
|
|
124
|
+
export function listenerIn(sockets, port, how) {
|
|
125
|
+
return proved(sockets.filter((s) => s.listening && same(s.local, ['127.0.0.1', port])), how);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
class ProofUnreadable extends Error {}
|
|
129
|
+
|
|
130
|
+
// One bounded run of lsof; its exit 1 with nothing printed is "no socket matched", not a failure.
|
|
131
|
+
export function runLsof(file, args, timeout = TIMEOUT_MS) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
execFile(file, args, { timeout, maxBuffer: MAX_OUTPUT_BYTES, encoding: 'utf8' }, (err, stdout) => {
|
|
134
|
+
if (!err) return resolve(stdout);
|
|
135
|
+
if (err.code === 1 && !String(stdout ?? '').trim()) return resolve('');
|
|
136
|
+
const why = typeof err.code === 'number' ? `exit ${err.code}` : (err.killed ? 'timeout' : (err.code ?? 'error'));
|
|
137
|
+
reject(new ProofUnreadable(`lsof: ${err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? `output over ${MAX_OUTPUT_BYTES} bytes` : why}`));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The reader a question uses: lsof on macOS, none elsewhere.
|
|
143
|
+
export const method = (platform = process.platform) => (platform === 'darwin' ? LSOF_METHOD : 'none');
|
|
144
|
+
|
|
145
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
146
|
+
|
|
147
|
+
async function ask(port, state, pick, waitMs, { platform = process.platform, uid, euid, run = runLsof, now = () => Date.now() } = {}) {
|
|
148
|
+
const start = now();
|
|
149
|
+
if (platform !== 'darwin') return proof(false, null, 'none', `${TABLE_RESTRICTED}: unsupported-platform`);
|
|
150
|
+
const real = uid ?? process.getuid();
|
|
151
|
+
const effective = euid ?? (typeof process.geteuid === 'function' ? process.geteuid() : real);
|
|
152
|
+
if (real !== effective) return proof(false, null, LSOF_METHOD, `${TABLE_RESTRICTED}: setuid-context`);
|
|
153
|
+
let pause = 5;
|
|
154
|
+
for (;;) {
|
|
155
|
+
let have;
|
|
156
|
+
try {
|
|
157
|
+
have = parseLsofSockets(await run(LSOF, ['-nP', '-a', '-u', String(real), `-iTCP:${port}`, `-sTCP:${state}`, '-F', 'pun']), real);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return proof(false, null, LSOF_METHOD, `${TABLE_RESTRICTED}: ${err?.message ?? 'lsof: error'}`);
|
|
160
|
+
}
|
|
161
|
+
const answer = pick(have);
|
|
162
|
+
if (answer.proved || now() + pause > start + Math.min(waitMs, TIMEOUT_MS)) return answer;
|
|
163
|
+
await sleep(pause);
|
|
164
|
+
pause = Math.min(pause * 2, 50);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// RT-2: whether the socket at `server` connected to `client` ([host, port] each) is in one of this account's processes.
|
|
169
|
+
// `wait`: read again for ACCEPT_WAIT_MS, since the server's accepted socket exists only once it calls accept().
|
|
170
|
+
// `platform`, `uid`, `euid`, `run` and `now` are the tests'.
|
|
171
|
+
export function accepted(server, client, { wait = false, ...opts } = {}) {
|
|
172
|
+
const s = [host(server[0]), Number(server[1])];
|
|
173
|
+
const c = [host(client[0]), Number(client[1])];
|
|
174
|
+
return ask(s[1], 'ESTABLISHED', (have) => acceptedIn(have, s, c, LSOF_METHOD), wait ? ACCEPT_WAIT_MS : 0, opts);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// FAA-272: whether one of this account's processes listens on exactly 127.0.0.1:<port>.
|
|
178
|
+
export function ownListener(port, opts = {}) {
|
|
179
|
+
return ask(Number(port), 'LISTEN', (have) => listenerIn(have, Number(port), LSOF_METHOD), 0, opts);
|
|
180
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { DRAIN_SECONDS, SUPERVISOR_ENV, ServiceError, TASK, UNIT } from './fabri
|
|
|
9
9
|
import { atomicWrite, instanceDirs } from './fabric-paths.mjs';
|
|
10
10
|
import { SUPERVISOR_ARG, markerArguments } from './fabric-lock.mjs';
|
|
11
11
|
import { URI } from './fabric-backups.mjs';
|
|
12
|
-
import { CheckedAgent,
|
|
12
|
+
import { CheckedAgent, ListenerRefused, PS, SERVER_NOT_OWN, TABLE_RESTRICTED, UNKNOWN, parsePsUid, pidListens,
|
|
13
13
|
runProgram, serverEnd } from './fabric-peer-owner.mjs';
|
|
14
14
|
import http from 'node:http';
|
|
15
15
|
import net from 'node:net';
|
|
@@ -60,7 +60,7 @@ function noMarkerArgument(argv) {
|
|
|
60
60
|
// An environment value: read path-aware (secretArgument) only when it reads as a path (it starts as one, has at least two
|
|
61
61
|
// separators and a lower-case word between them), so a release folder under Application Support is no key (FAA-266) while
|
|
62
62
|
// a base64 key whose `/` splits it stays one; any other value keeps the strict secretValue.
|
|
63
|
-
const secretEnvValue = (value) => {
|
|
63
|
+
export const secretEnvValue = (value) => {
|
|
64
64
|
const pieces = value.split(/[\\/]/);
|
|
65
65
|
const path = /^(?:[/~.]|[A-Za-z]:[\\/])/.test(value) && pieces.length >= 3 && pieces.some((x) => /^[a-z][a-z0-9_-]{2,}$/.test(x));
|
|
66
66
|
return path ? secretArgument(value) : secretValue(value);
|
|
@@ -609,16 +609,9 @@ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
|
|
|
609
609
|
}
|
|
610
610
|
|
|
611
611
|
// Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
|
|
612
|
-
// which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
try {
|
|
616
|
-
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
617
|
-
return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
|
|
618
|
-
} catch {
|
|
619
|
-
return false;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
612
|
+
// which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named. The
|
|
613
|
+
// peer owner's one lsof reader, the uid left to the caller, which checks the job's UserName itself (FAA-286 item 1).
|
|
614
|
+
const jobListens = (pid, port, run = runProgram, platform = process.platform) => pidListens(pid, port, { run, platform, owner: null });
|
|
622
615
|
|
|
623
616
|
// The uid account `name` has on this machine (`id -u`), or null when it has none.
|
|
624
617
|
function accountUid(name, run) {
|