@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -0
- package/README.md +18 -5
- package/SECURITY.md +25 -8
- package/bin/fabric-agent-adapter.js +24 -1
- 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 +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- 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 +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -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 +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
- 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 +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- 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/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -38,13 +38,42 @@ export function credentialName(name) {
|
|
|
38
38
|
|
|
39
39
|
/** A JWT or JWS anywhere in a value. */
|
|
40
40
|
export const JWT_SHAPE = /eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*/;
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const isUrlBase64 = (code) => (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || (code >= 0x30 && code <= 0x39)
|
|
42
|
+
|| code === 0x2d || code === 0x5f;
|
|
43
|
+
const runEnd = (text, from) => { let end = from; while (end < text.length && isUrlBase64(text.charCodeAt(end))) end += 1; return end; };
|
|
44
|
+
/** The spans JWT_SHAPE matches in a text, leftmost and not overlapping, read in one pass (the contract's jwtSpans,
|
|
45
|
+
* FAA-313): the regular expression backtracks from every `eyJ` of a long run, quadratic in it, while every `eyJ` of one
|
|
46
|
+
* run shares that run's end, so only its first is tried, and a match ends where its third segment's run ends. `first`
|
|
47
|
+
* stops at the first match. */
|
|
48
|
+
export function jwtSpans(text, first = false) {
|
|
49
|
+
const spans = [];
|
|
50
|
+
let from = 0;
|
|
51
|
+
for (let at = text.indexOf('eyJ', from); at >= 0; at = text.indexOf('eyJ', from)) {
|
|
52
|
+
const one = runEnd(text, at + 3);
|
|
53
|
+
from = one;
|
|
54
|
+
if (one - at - 3 < 2 || text[one] !== '.') continue;
|
|
55
|
+
const two = runEnd(text, one + 1);
|
|
56
|
+
if (two - one - 1 < 2 || text[two] !== '.') continue;
|
|
57
|
+
const three = runEnd(text, two + 1);
|
|
58
|
+
spans.push([at, three]);
|
|
59
|
+
if (first) break;
|
|
60
|
+
from = three;
|
|
61
|
+
}
|
|
62
|
+
return spans;
|
|
63
|
+
}
|
|
64
|
+
/** Whether JWT_SHAPE matches anywhere in a text, read in one pass (the contract's hasJwt, FAA-313). */
|
|
65
|
+
export const hasJwt = (text) => jwtSpans(text, true).length > 0;
|
|
66
|
+
/** A value that says it is a credential (`Bearer ` at its start, in any case), or a provider key's shape with its prefix
|
|
67
|
+
* in its canonical case only, at the start of a value, after a character that is no ASCII letter or digit, or after an
|
|
68
|
+
* escape; an `sk-` key holds a digit and an upper-case letter with 16 or more characters, or a digit with 32 or more,
|
|
69
|
+
* within 256 characters (the contract's SECRET_VALUE at the pin, DEC-0151). A match includes its boundary character:
|
|
70
|
+
* callers test, never read the matched text or its index. */
|
|
71
|
+
export const SECRET_VALUE = /(^[Bb][Ee][Aa][Rr][Ee][Rr]\s.+|(?:^|[^A-Za-z0-9]|%[0-9A-Fa-f]{2}|\\[nrt"']|\\u[0-9A-Fa-f]{4}|\\x[0-9A-Fa-f]{2}|\\[bfv0ae]|\\[0-7]{1,3}|%25[0-9A-Fa-f]{2}|\x1b\[[0-9;:]*m|(?:\\u001[bB]|\\x1[bB]|\\e|\\033|\\33)\[[0-9;:]*m)(?:sk-(?=[A-Za-z0-9_-]{0,255}?\d)(?:(?=[A-Za-z0-9_-]{0,255}?[A-Z])[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{32,})|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}))/;
|
|
43
72
|
const LONG_RUN = /[A-Za-z0-9+/=_-]{40,}/g;
|
|
44
73
|
export const longSecret = (value) => (value.match(LONG_RUN) ?? []).some((run) => /[a-z]/.test(run) && /[A-Z]/.test(run) && /[0-9]/.test(run));
|
|
45
74
|
|
|
46
75
|
/** Whether a value could be a credential: a JWT anywhere, a provider key or `Bearer …`, or a long secret-shaped run. */
|
|
47
|
-
export const secretValue = (value) =>
|
|
76
|
+
export const secretValue = (value) => hasJwt(value) || SECRET_VALUE.test(value) || longSecret(value);
|
|
48
77
|
|
|
49
78
|
export const BEARER_INSIDE = /\bbearer\s+(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{12,}=*/i;
|
|
50
79
|
const PATH_LIKE = /^(?:[/~.]|[A-Za-z]:[\\/]|[a-z][a-z0-9+.-]*:\/\/)/i;
|
|
@@ -54,7 +83,7 @@ const pathLike = (piece) =>
|
|
|
54
83
|
|
|
55
84
|
/** The same for a launch argument or a server's command and URL: a long run is looked for between path separators. */
|
|
56
85
|
export const secretArgument = (value) =>
|
|
57
|
-
|
|
86
|
+
hasJwt(value) || SECRET_VALUE.test(value) || BEARER_INSIDE.test(value) ||
|
|
58
87
|
value.split(/[\s=]/).some((piece) => (pathLike(piece) ? piece.split(/[\\/]/).some((part) => longSecret(part)) : longSecret(piece)));
|
|
59
88
|
|
|
60
89
|
const decodeKeepingPlus = (raw) => { try { return decodeURIComponent(raw); } catch { return raw; } };
|
|
@@ -91,7 +120,10 @@ export function secretUrl(value) {
|
|
|
91
120
|
const REPOSITORY_URI = /^([A-Za-z][A-Za-z0-9+.-]*:(?:\/\/[^/?#]*)?)([^?#]*)([\s\S]*)$/;
|
|
92
121
|
const REPOSITORY_NAME = /^(?:[a-z0-9._-]+|[A-Za-z._-]+)$/; // lower case, or letters with no digit (readings 8)
|
|
93
122
|
const REPOSITORY_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
94
|
-
|
|
123
|
+
// A repository name's own key shapes, boundary-free and case-sensitive (DEC-0151): SECRET_VALUE takes a consuming
|
|
124
|
+
// boundary, so composed after a separator look-behind it would miss a key after `-`, `.` or `_`.
|
|
125
|
+
const REPOSITORY_KEYS = 'sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,}';
|
|
126
|
+
const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${REPOSITORY_KEYS})`);
|
|
95
127
|
const JWT_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))${JWT_SHAPE.source}`);
|
|
96
128
|
const SPECIAL_QUERY = /^(?:https?|wss?|ftp|file):/i; // whose query also encodes `'`, as the URL standard does
|
|
97
129
|
|
|
@@ -115,7 +147,8 @@ export function repositoryPassword(value) {
|
|
|
115
147
|
|
|
116
148
|
// A repository's parameters, read as fabric_secrets.secret_url reads them, by hand: `new URL`
|
|
117
149
|
// refuses a host it cannot parse and a special scheme written without `//` (`https:host/r?access_token=...`), where
|
|
118
|
-
// the Python twin
|
|
150
|
+
// the Python twin's repository_credential reads them too (0.62.0 readings 8 and 9, FAA-200 (17)); its secret_url, like
|
|
151
|
+
// secretUrl, refuses such a URL since FAA-309.
|
|
119
152
|
function repositoryParts(head, tail) {
|
|
120
153
|
const text = tail.replace(/[\x00-\x20]+$/, '').replace(/[\t\n\r]/g, '');
|
|
121
154
|
const hash = text.indexOf('#');
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -52,8 +52,11 @@ export {
|
|
|
52
52
|
LOCK_WAIT_SUPERVISED_SECONDS,
|
|
53
53
|
LOCK_BACKOFF_FIRST_SECONDS,
|
|
54
54
|
LOCK_BACKOFF_MAX_SECONDS,
|
|
55
|
+
LOCK_WATCH_SECONDS,
|
|
55
56
|
AlreadyRunning,
|
|
56
57
|
InstanceLock,
|
|
58
|
+
lostLockMessage,
|
|
59
|
+
stopLostHolder,
|
|
57
60
|
lockName,
|
|
58
61
|
SUPERVISORS,
|
|
59
62
|
SUPERVISOR_ARG,
|
|
@@ -107,6 +110,10 @@ export {
|
|
|
107
110
|
STATUSES,
|
|
108
111
|
PLACEMENTS,
|
|
109
112
|
MANAGERS,
|
|
113
|
+
PEER_CHECKS,
|
|
114
|
+
connectionRefusal,
|
|
115
|
+
connectionOwner,
|
|
116
|
+
loopbackServer,
|
|
110
117
|
descriptorPath,
|
|
111
118
|
readDescriptors,
|
|
112
119
|
validateDescriptor,
|
|
@@ -185,6 +192,7 @@ export {
|
|
|
185
192
|
taskUninstall,
|
|
186
193
|
launchdPlist,
|
|
187
194
|
SYSTEM_DAEMONS,
|
|
195
|
+
USER_NAME_RULE,
|
|
188
196
|
launchdDomain,
|
|
189
197
|
wellKnownOf,
|
|
190
198
|
jobWellKnownOf,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -343,6 +343,8 @@ function guiDomain(platform, uid) {
|
|
|
343
343
|
/** Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278). */
|
|
344
344
|
export const SYSTEM_DAEMONS = '/Library/LaunchDaemons';
|
|
345
345
|
const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
346
|
+
/** The short-name rule in words, printed alike by both twins (FAA-286 item 13). */
|
|
347
|
+
export const USER_NAME_RULE = 'a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -';
|
|
346
348
|
|
|
347
349
|
/**
|
|
348
350
|
* The launchd domain a job lives in: `system` for a LaunchDaemon (`domain: 'system'`, or a plist in
|
|
@@ -351,6 +353,9 @@ const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
|
351
353
|
*/
|
|
352
354
|
export function launchdDomain(plistPath = null, { domain = null, platform = process.platform, uid } = {}) {
|
|
353
355
|
if (![null, undefined, 'gui', 'system'].includes(domain)) throw new ServiceError('domain must be gui or system');
|
|
356
|
+
if (plistPath !== null && plistPath !== undefined && /^(?:system|gui)(?:\/[0-9]*)?$/.test(String(plistPath))) {
|
|
357
|
+
throw new ServiceError(`${plistPath} is a launchd domain, not a plist path: pass { domain: '${String(plistPath) === 'system' ? 'system' : 'gui'}' } (FAA-286)`);
|
|
358
|
+
}
|
|
354
359
|
let which = domain;
|
|
355
360
|
if ((which === null || which === undefined) && plistPath !== null && plistPath !== undefined) {
|
|
356
361
|
const paths = platform === 'darwin' ? path.posix : path; // a macOS path, whatever host reads it
|
|
@@ -363,6 +368,34 @@ export function launchdDomain(plistPath = null, { domain = null, platform = proc
|
|
|
363
368
|
return guiDomain(platform, uid);
|
|
364
369
|
}
|
|
365
370
|
|
|
371
|
+
// A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd also
|
|
372
|
+
// loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root). Another
|
|
373
|
+
// folder is allowed for a system job, as the tests' and a staging install's are.
|
|
374
|
+
function pairProblem(plistPath, domain, platform) {
|
|
375
|
+
const paths = platform === 'darwin' ? path.posix : path;
|
|
376
|
+
const folder = paths.dirname(paths.resolve(String(plistPath)));
|
|
377
|
+
const parts = String(plistPath).split(/[\\/]+/); // the folder's own name, whatever separator a test's host path uses
|
|
378
|
+
if (domain === 'system' && String(parts[parts.length - 2] ?? '').toLowerCase() === 'launchagents') {
|
|
379
|
+
return `A LaunchDaemon's plist lives in ${SYSTEM_DAEMONS}: ${plistPath} is in a LaunchAgents folder, which launchd also loads into gui/<uid> at login, so the job would run twice.`;
|
|
380
|
+
}
|
|
381
|
+
if (domain === 'gui' && folder.toLowerCase() === SYSTEM_DAEMONS.toLowerCase()) {
|
|
382
|
+
return `${plistPath} is in ${SYSTEM_DAEMONS}, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in ~/Library/LaunchAgents.`;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A LaunchAgent of `label` in `userName`'s ~/Library/LaunchAgents, which would run a second copy at its login (FAA-286
|
|
388
|
+
// item 6); null when there is none or the home cannot be read.
|
|
389
|
+
function leftoverAgent(label, userName, run) {
|
|
390
|
+
const read = run(['dscl', '.', '-read', `/Users/${userName}`, 'NFSHomeDirectory']);
|
|
391
|
+
const home = read?.status === 0 ? /NFSHomeDirectory:\s*(\S.*?)\s*$/m.exec(String(read.stdout ?? ''))?.[1] : null;
|
|
392
|
+
if (!home) return null;
|
|
393
|
+
const folder = path.join(home, 'Library', 'LaunchAgents');
|
|
394
|
+
let names = [];
|
|
395
|
+
try { names = fs.readdirSync(folder).filter((n) => n.endsWith('.plist')).sort(); } catch { return null; }
|
|
396
|
+
return names.map((n) => path.join(folder, n)).find((file) => topKey(plistXml(file, run), 'Label') === label) ?? null;
|
|
397
|
+
}
|
|
398
|
+
|
|
366
399
|
// A system-domain job is installed and removed by root: refuse before anything is written (FAA-278).
|
|
367
400
|
function needRoot(domain, label, plistPath, install, euid) {
|
|
368
401
|
if (domain !== 'system' || euid === 0) return;
|
|
@@ -390,6 +423,9 @@ function decodeXml(text) {
|
|
|
390
423
|
function topStrings(text) {
|
|
391
424
|
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z][\w.:-]*)\s*[^>]*?(\/?)>|([^<]+)/g;
|
|
392
425
|
const values = Object.create(null);
|
|
426
|
+
// The open containers: only the root dict's own keys are read (FAA-286 item 10: a root <array> holding a
|
|
427
|
+
// <key>UserName</key> is no dict, as plistlib reads it).
|
|
428
|
+
const open = [];
|
|
393
429
|
let depth = 0;
|
|
394
430
|
let key = null;
|
|
395
431
|
let capture = null;
|
|
@@ -398,17 +434,21 @@ function topStrings(text) {
|
|
|
398
434
|
for (let m = token.exec(String(text ?? '')); m; m = token.exec(String(text ?? ''))) {
|
|
399
435
|
const [, cdata, closing, name, selfClosing, chars] = m;
|
|
400
436
|
if (cdata !== undefined || chars !== undefined) {
|
|
401
|
-
|
|
437
|
+
// every character reference, at any depth and in any value, is checked as expat checks it (FAA-286 item 10)
|
|
438
|
+
const decoded = chars !== undefined ? decodeXml(chars) : cdata;
|
|
439
|
+
if (capture) buffer += decoded;
|
|
402
440
|
} else if (name === 'dict' || name === 'array') {
|
|
403
441
|
if (selfClosing) {
|
|
404
442
|
if (depth === 1 && key !== null) { delete values[key]; key = null; }
|
|
405
443
|
} else if (closing) {
|
|
406
444
|
depth -= 1;
|
|
445
|
+
open.pop();
|
|
407
446
|
} else {
|
|
408
447
|
if (depth === 1 && key !== null) { delete values[key]; key = null; } // a container is this key's value
|
|
409
448
|
depth += 1;
|
|
449
|
+
open.push(name);
|
|
410
450
|
}
|
|
411
|
-
} else if (name && depth === 1) {
|
|
451
|
+
} else if (name && depth === 1 && open[0] === 'dict') {
|
|
412
452
|
if (selfClosing) {
|
|
413
453
|
if (key !== null) {
|
|
414
454
|
if (name === 'string') values[key] = '';
|
|
@@ -431,6 +471,7 @@ function topStrings(text) {
|
|
|
431
471
|
} catch {
|
|
432
472
|
return null;
|
|
433
473
|
}
|
|
474
|
+
if (key !== null || capture !== null || depth !== 0) return null; // a dangling <key> or an open element: unreadable
|
|
434
475
|
return values;
|
|
435
476
|
}
|
|
436
477
|
|
|
@@ -569,8 +610,8 @@ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
|
|
|
569
610
|
|
|
570
611
|
// Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
|
|
571
612
|
// which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
|
|
572
|
-
async function jobListens(pid, port, run = runProgram) {
|
|
573
|
-
if (
|
|
613
|
+
async function jobListens(pid, port, run = runProgram, platform = process.platform) {
|
|
614
|
+
if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
574
615
|
try {
|
|
575
616
|
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
576
617
|
return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
|
|
@@ -600,7 +641,11 @@ class JobAgent extends http.Agent {
|
|
|
600
641
|
this.ask = ask;
|
|
601
642
|
}
|
|
602
643
|
|
|
603
|
-
createConnection(options,
|
|
644
|
+
createConnection(options, given) {
|
|
645
|
+
// One answer per connection (FAA-286 item 12): an error during the async owner, lsof and ps checks would reach the
|
|
646
|
+
// callback a second time, as CheckedAgent's checkedSocket guards it.
|
|
647
|
+
let answered = false;
|
|
648
|
+
const callback = (err, socket) => { if (!answered) { answered = true; given(err, socket); } };
|
|
604
649
|
const port = Number(options.port);
|
|
605
650
|
const socket = net.connect({ host: '127.0.0.1', port, noDelay: true });
|
|
606
651
|
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), options.timeout ?? 10000);
|
|
@@ -614,14 +659,14 @@ class JobAgent extends http.Agent {
|
|
|
614
659
|
} catch (err) {
|
|
615
660
|
who = { kind: UNKNOWN, uid: null, pid: null, reason: `check: ${err?.name ?? 'Error'}` };
|
|
616
661
|
}
|
|
617
|
-
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run)) {
|
|
662
|
+
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run, this.ask.platform)) {
|
|
618
663
|
socket.destroy();
|
|
619
664
|
// The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
|
|
620
665
|
callback(new ListenerRefused(SERVER_NOT_OWN, `the LaunchDaemon's pid ${this.jobPid} is not shown listening on exactly 127.0.0.1:${port} by lsof (a wildcard is never vouched for); nothing was sent`));
|
|
621
666
|
return;
|
|
622
667
|
}
|
|
623
668
|
const proved = who.pid === this.jobPid || (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED)
|
|
624
|
-
&& await jobListens(this.jobPid, port, this.ask.run));
|
|
669
|
+
&& await jobListens(this.jobPid, port, this.ask.run, this.ask.platform));
|
|
625
670
|
if (!proved) {
|
|
626
671
|
socket.destroy();
|
|
627
672
|
callback(new ListenerRefused(SERVER_NOT_OWN, `the server on port ${port} is not the LaunchDaemon's pid ${this.jobPid} (${who.reason ?? who.kind}): another process holds the port, or the job's program starts the server as a child (start it with exec); nothing was sent`, who));
|
|
@@ -649,11 +694,11 @@ function loopbackPort(origin) {
|
|
|
649
694
|
}
|
|
650
695
|
|
|
651
696
|
// As wellKnownOf, for a LaunchDaemon: a loopback origin is read only when launchd's job `jobPid`, running as `jobUid`,
|
|
652
|
-
// accepted the connection.
|
|
653
|
-
export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
697
|
+
// accepted the connection. `ask` reaches the owner, lsof and ps readers (a test's fakes; `platform` for lsof's).
|
|
698
|
+
export async function jobWellKnownOf(origin, { jobPid, jobUid, ask = {} } = {}) {
|
|
654
699
|
const port = loopbackPort(origin);
|
|
655
700
|
if (port === null) throw new ServiceError(`A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid prove it; ${origin} is not.`);
|
|
656
|
-
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid }));
|
|
701
|
+
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid, ask }));
|
|
657
702
|
}
|
|
658
703
|
|
|
659
704
|
/**
|
|
@@ -672,9 +717,15 @@ export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
|
672
717
|
export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
|
|
673
718
|
run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), fetchWellKnown = wellKnownOf,
|
|
674
719
|
fetchJobWellKnown = jobWellKnownOf, sleep = pause } = {}) {
|
|
720
|
+
const pair = pairProblem(plistPath, domain, platform);
|
|
721
|
+
if (pair) throw new ServiceError(pair);
|
|
675
722
|
const dom = launchdDomain(plistPath, { domain, platform, uid });
|
|
676
723
|
launchdLabel(label);
|
|
677
724
|
if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
|
|
725
|
+
if (/^bplist/.test(Buffer.isBuffer(plistText) ? plistText.toString('latin1', 0, 6) : String(plistText ?? '').slice(0, 6))) {
|
|
726
|
+
// named as what it is, never as a missing UserName (FAA-286 item 7): the text launchdPlist writes is XML
|
|
727
|
+
throw new ServiceError('The plist is a binary plist: pass the XML text launchdPlist writes (plutil -convert xml1 converts one).');
|
|
728
|
+
}
|
|
678
729
|
needRoot(dom, label, plistPath, true, euid);
|
|
679
730
|
if (dom === 'system' && loopbackPort(origin) === null) {
|
|
680
731
|
throw new ServiceError(`A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by the job's pid and uid (FAA-278); ${origin} is not.`);
|
|
@@ -693,7 +744,7 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
693
744
|
}
|
|
694
745
|
}
|
|
695
746
|
if (dom === 'system' && named && !USER_NAME.test(named)) {
|
|
696
|
-
throw new ServiceError(`UserName ${named} is not a macOS short name (${
|
|
747
|
+
throw new ServiceError(`UserName ${named} is not a macOS short name (${USER_NAME_RULE}): launchd takes the account's short name.`);
|
|
697
748
|
}
|
|
698
749
|
const jobUid = named ? accountUid(named, run) : null;
|
|
699
750
|
if (dom === 'system' && named && jobUid === null) {
|
|
@@ -705,7 +756,12 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
705
756
|
if (dom === 'system' && !named) {
|
|
706
757
|
throw new ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with launchdPlist(..., { userName: <the account whose Fabric and agents call it> }).");
|
|
707
758
|
}
|
|
759
|
+
if (dom === 'system') {
|
|
760
|
+
const leftover = leftoverAgent(label, named, run);
|
|
761
|
+
if (leftover) throw new ServiceError(`${leftover} is a LaunchAgent of label ${label}: it would start a second copy at ${named}'s login beside this LaunchDaemon. Remove it first (launchdUninstall as ${named}; lifecycle reference, The system domain).`);
|
|
762
|
+
}
|
|
708
763
|
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
764
|
+
const existed = fs.existsSync(plistPath);
|
|
709
765
|
atomicWrite(plistPath, plistText, 0o644);
|
|
710
766
|
const lint = run(['plutil', '-lint', String(plistPath)]);
|
|
711
767
|
if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
|
|
@@ -725,6 +781,11 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
725
781
|
if (dom !== 'system' && /:\s*125:|Domain does not support|Could not find domain/.test(last)) {
|
|
726
782
|
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}. This user has no GUI login session (an SSH session, or a headless Mac after a FileVault restart), so ${dom} does not run jobs: remove the LaunchAgent this attempt wrote (launchdUninstall), then install it as a LaunchDaemon in the system domain (a plist in ${SYSTEM_DAEMONS} with UserName, as root; FAA-278).`);
|
|
727
783
|
}
|
|
784
|
+
if (dom === 'system') {
|
|
785
|
+
// a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
|
|
786
|
+
if (!existed) { try { fs.unlinkSync(plistPath); } catch { /* named below */ } }
|
|
787
|
+
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}; ${fs.existsSync(plistPath) ? `${plistPath} stays and loads at the next boot: remove it as root` : 'the plist this call wrote was removed'}`);
|
|
788
|
+
}
|
|
728
789
|
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
|
|
729
790
|
}
|
|
730
791
|
await sleep(1000);
|
|
@@ -780,6 +841,10 @@ export async function launchdUninstall(label, plistPath, { timeout = 30, purge =
|
|
|
780
841
|
needRoot(dom, label, plistPath, false, euid);
|
|
781
842
|
if (purge && dom === 'system') throw new ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then remove the data as that user.");
|
|
782
843
|
if (dom === 'system') refuseHeldLabel(label, plistPath, run); // a bootout would stop another plist's job (FAA-278)
|
|
844
|
+
const holds = fs.existsSync(plistPath) ? topKey(plistXml(plistPath, run), 'Label') : null;
|
|
845
|
+
if (holds !== null && holds !== label) { // FAA-286 item 9: booting out one job and unlinking another's plist
|
|
846
|
+
throw new ServiceError(`${plistPath} holds launchd label ${holds}, not ${label}: nothing was removed. Name the plist of ${label}, or uninstall ${holds} with its own label.`);
|
|
847
|
+
}
|
|
783
848
|
const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
|
|
784
849
|
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
785
850
|
const target = `${dom}/${label}`;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py
CHANGED
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
`degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
import bisect
|
|
8
|
-
import ipaddress
|
|
9
7
|
import re
|
|
10
|
-
import unicodedata
|
|
11
8
|
import urllib.parse
|
|
12
9
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
|
13
10
|
|
|
@@ -18,7 +15,12 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
18
15
|
|
|
19
16
|
from fabric_base import ServiceError # noqa: E402
|
|
20
17
|
from fabric_secrets import ( # noqa: E402
|
|
21
|
-
|
|
18
|
+
_BEARER_INSIDE as _BEARER, _long_secret, credential_name, has_jwt, raw_values, secret_argument, secret_value,
|
|
19
|
+
)
|
|
20
|
+
# The WHATWG host reading and its IDNA tables (FAA-98) live in fabric_secrets since FAA-309, where secret_url reads a
|
|
21
|
+
# URL's host with them; the names stay here for code and tests that reached them.
|
|
22
|
+
from fabric_secrets import ( # noqa: E402,F401
|
|
23
|
+
_FORBIDDEN_DOMAIN, _FORBIDDEN_HOST, _IDNA_DISALLOWED, _IDNA_IGNORED, _domain_ok, _idna_host, _ipv4_number, _ipv6_ok,
|
|
22
24
|
)
|
|
23
25
|
|
|
24
26
|
CONNECTIONS_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1"
|
|
@@ -171,153 +173,6 @@ _credential_name, _secret_value, _secret_argument = credential_name, secret_valu
|
|
|
171
173
|
|
|
172
174
|
|
|
173
175
|
_SPECIAL = ("http", "https", "ws", "wss", "ftp", "file")
|
|
174
|
-
_FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
175
|
-
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
|
|
179
|
-
# the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
|
|
180
|
-
# test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
|
|
181
|
-
# code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
|
|
182
|
-
# classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
|
|
183
|
-
# Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
|
|
184
|
-
_IDNA_DISALLOWED = (
|
|
185
|
-
(0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
|
|
186
|
-
(0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
|
|
187
|
-
(0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
|
|
188
|
-
(0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
|
|
189
|
-
(0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
|
|
190
|
-
(0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
|
|
191
|
-
(0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
|
|
192
|
-
(0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
|
|
193
|
-
(0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
|
|
194
|
-
(0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
|
|
195
|
-
(0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
|
|
196
|
-
(0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
|
|
197
|
-
(0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
|
|
198
|
-
(0x100000, 0x10fffd),
|
|
199
|
-
)
|
|
200
|
-
_IDNA_IGNORED = (
|
|
201
|
-
(0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
|
|
202
|
-
(0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
|
|
203
|
-
)
|
|
204
|
-
_IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
|
|
205
|
-
_U32 = unicodedata.ucd_3_2_0
|
|
206
|
-
_RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
|
|
207
|
-
_JOINERS = "\u200c\u200d"
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
|
|
211
|
-
i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
|
|
212
|
-
return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def _known(c: str) -> bool:
|
|
216
|
-
return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _joiner_refused(label: str, i: int) -> bool:
|
|
220
|
-
"""CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
|
|
221
|
-
(`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
|
|
222
|
-
in its label has none for certain."""
|
|
223
|
-
return all(c.isascii() for j, c in enumerate(label) if j != i)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def _bidi_refused(label: str) -> bool:
|
|
227
|
-
"""RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
|
|
228
|
-
not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
|
|
229
|
-
if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
|
|
230
|
-
return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
|
|
231
|
-
k = [_U32.bidirectional(c) for c in label]
|
|
232
|
-
if not any(x in ("R", "AL", "AN") for x in k):
|
|
233
|
-
return False
|
|
234
|
-
body = list(k)
|
|
235
|
-
while body and body[-1] == "NSM":
|
|
236
|
-
body.pop()
|
|
237
|
-
if not body or body[-1] not in ("R", "AL", "EN", "AN"):
|
|
238
|
-
return True
|
|
239
|
-
return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def _idna_host(host: str) -> Optional[str]:
|
|
243
|
-
"""None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
|
|
244
|
-
as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
|
|
245
|
-
if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
|
|
246
|
-
return None
|
|
247
|
-
kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
|
|
248
|
-
if kept == "":
|
|
249
|
-
return None # nothing left: an empty host
|
|
250
|
-
if not all(_known(c) for c in kept):
|
|
251
|
-
return host # a code point this Python's Unicode does not know: the contextual rules are not read
|
|
252
|
-
mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
|
|
253
|
-
for label in _IDNA_DOTS.split(mapped):
|
|
254
|
-
first = label[:1]
|
|
255
|
-
if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
|
|
256
|
-
return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
|
|
257
|
-
if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
|
|
258
|
-
return None
|
|
259
|
-
if _bidi_refused(label):
|
|
260
|
-
return None
|
|
261
|
-
return _IDNA_DOTS.sub(".", mapped)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def _ipv4_number(part: str) -> Optional[int]:
|
|
265
|
-
if part == "":
|
|
266
|
-
return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
|
|
267
|
-
base = 10
|
|
268
|
-
if part[:2].lower() == "0x":
|
|
269
|
-
part, base = part[2:], 16
|
|
270
|
-
elif len(part) > 1 and part[0] == "0":
|
|
271
|
-
part, base = part[1:], 8
|
|
272
|
-
if part == "":
|
|
273
|
-
return 0
|
|
274
|
-
digits = {10: "0123456789", 16: "0123456789abcdefABCDEF", 8: "01234567"}[base]
|
|
275
|
-
return int(part, base) if all(c in digits for c in part) else None
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def _domain_ok(host: str) -> bool:
|
|
279
|
-
"""The WHATWG host parser on a special scheme's host, enough to know when `new URL` refuses it."""
|
|
280
|
-
if host.startswith("["):
|
|
281
|
-
return host.endswith("]") and _ipv6_ok(host[1:-1])
|
|
282
|
-
if re.search(r"%(?![0-9A-Fa-f]{2})", host) is None:
|
|
283
|
-
try:
|
|
284
|
-
host = urllib.parse.unquote_to_bytes(host).decode("utf-8")
|
|
285
|
-
except UnicodeDecodeError:
|
|
286
|
-
return False
|
|
287
|
-
if any(c in _FORBIDDEN_DOMAIN for c in host):
|
|
288
|
-
return False
|
|
289
|
-
# A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
|
|
290
|
-
# the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
291
|
-
# (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
|
|
292
|
-
if any(ord(c) > 0x7f for c in host):
|
|
293
|
-
mapped = _idna_host(host)
|
|
294
|
-
if mapped is None:
|
|
295
|
-
return False
|
|
296
|
-
if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
|
|
297
|
-
return True
|
|
298
|
-
host = mapped
|
|
299
|
-
labels = host.split(".")
|
|
300
|
-
if labels and labels[-1] == "" and len(labels) > 1:
|
|
301
|
-
labels = labels[:-1]
|
|
302
|
-
last = labels[-1] if labels else ""
|
|
303
|
-
if not (last and (last.isdigit() and last.isascii() or re.fullmatch(r"0[xX][0-9A-Fa-f]*", last))):
|
|
304
|
-
return host != ""
|
|
305
|
-
numbers = [_ipv4_number(label) for label in labels]
|
|
306
|
-
if len(numbers) > 4 or any(n is None for n in numbers):
|
|
307
|
-
return False
|
|
308
|
-
return all(n <= 255 for n in numbers[:-1]) and numbers[-1] < 256 ** (5 - len(numbers))
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _ipv6_ok(text: str) -> bool:
|
|
312
|
-
if "%" in text:
|
|
313
|
-
return False # a zone id: `ipaddress` takes one, WHATWG does not
|
|
314
|
-
try:
|
|
315
|
-
ipaddress.IPv6Address(text)
|
|
316
|
-
return True
|
|
317
|
-
except ValueError:
|
|
318
|
-
return False
|
|
319
|
-
|
|
320
|
-
|
|
321
176
|
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
322
177
|
_QUERY_ENCODE = set(chr(c) for c in range(0x21)) | set('"#<>')
|
|
323
178
|
_FRAGMENT_ENCODE = set(chr(c) for c in range(0x21)) | set('"<>`')
|
|
@@ -388,7 +243,7 @@ def _whatwg_parts(value: str) -> Optional[Dict[str, str]]:
|
|
|
388
243
|
return None
|
|
389
244
|
elif any(c in _FORBIDDEN_HOST for c in host):
|
|
390
245
|
return None
|
|
391
|
-
elif host == "" and ("@" in authority or
|
|
246
|
+
elif host == "" and ("@" in authority or ":" in hostport):
|
|
392
247
|
return None
|
|
393
248
|
return {"password": password, "query": query, "fragment": fragment}
|
|
394
249
|
|
|
@@ -424,7 +279,7 @@ def _secret_url(value: str) -> bool:
|
|
|
424
279
|
|
|
425
280
|
|
|
426
281
|
def _credential(value: str, runs: bool) -> bool:
|
|
427
|
-
return bool(
|
|
282
|
+
return bool(has_jwt(value) or _KEY_SHAPE.search(value) or _BEARER.search(value) # has_jwt: one pass (FAA-313)
|
|
428
283
|
or (runs and _long_run(value)) or any(_secret_url(url) for url in _urls_in(value)))
|
|
429
284
|
|
|
430
285
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -5,12 +5,14 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import http.server
|
|
8
|
+
import ipaddress
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
11
12
|
import socket
|
|
12
13
|
import socketserver
|
|
13
14
|
import subprocess
|
|
15
|
+
import threading
|
|
14
16
|
import warnings
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
@@ -53,7 +55,7 @@ from fabric_usage import (
|
|
|
53
55
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
54
56
|
|
|
55
57
|
|
|
56
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.
|
|
58
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.7"
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
@@ -88,19 +90,101 @@ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
|
|
|
88
90
|
_AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
#: How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
|
|
94
|
+
#: FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of
|
|
95
|
+
#: it and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account
|
|
96
|
+
#: other than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table.
|
|
97
|
+
PEER_CHECKS = ("refuse", "strict", "off")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def connection_refusal(owner: Any, mode: str = "refuse") -> Optional[str]:
|
|
101
|
+
"""Why a server refuses the peer `owner` (a fabric_peer_owner.Owner) under `mode`, or None to serve it. The reason
|
|
102
|
+
is the owner's kind, or for `unknown` its reason; it names no address and nothing the peer sent."""
|
|
103
|
+
if mode not in PEER_CHECKS:
|
|
104
|
+
raise ValueError("peer_check must be one of %s" % ", ".join(PEER_CHECKS))
|
|
105
|
+
if mode == "off" or owner is None:
|
|
106
|
+
return None
|
|
107
|
+
kind = getattr(owner, "kind", fabric_peer_owner.UNKNOWN)
|
|
108
|
+
if kind == fabric_peer_owner.ANOTHER_ACCOUNT:
|
|
109
|
+
return kind
|
|
110
|
+
if mode == "strict" and kind != fabric_peer_owner.THIS_ACCOUNT:
|
|
111
|
+
if kind == fabric_peer_owner.SYSTEM and getattr(owner, "uid", None) == 0:
|
|
112
|
+
return None
|
|
113
|
+
return getattr(owner, "reason", None) or kind
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_loopback_peer(client_address: Any) -> bool:
|
|
118
|
+
"""Whether an accepted connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the
|
|
119
|
+
only peers whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read
|
|
120
|
+
counts as loopback, so its owner is still read from the table."""
|
|
121
|
+
try:
|
|
122
|
+
return ipaddress.ip_address(fabric_peer_owner.norm_host(str(client_address[0]))).is_loopback
|
|
123
|
+
except Exception:
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
91
127
|
class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
|
|
92
128
|
"""ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
|
|
93
129
|
no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
|
|
94
130
|
on a Mac with a slow resolver the port then stays bound but silent, so a host sees
|
|
95
|
-
neither an answer nor a refusal.
|
|
131
|
+
neither an answer nor a refusal.
|
|
132
|
+
|
|
133
|
+
Each accepted connection's peer is told from the computer's socket table (`fabric_peer_owner.peer`) in the
|
|
134
|
+
connection's own thread, before the handler reads a byte (DEC-0154 HA-2's server side, FAA-227). `peer_check`
|
|
135
|
+
(`PEER_CHECKS`) decides which owners are closed unread; a served handler finds its peer's owner with
|
|
136
|
+
`self.server.peer_owner()`. A refusal writes one line to stderr with the owner's kind, uid and pid and nothing the
|
|
137
|
+
peer sent (`peer_refused`), and counts in `peer_refusals`. The check applies to loopback peers only: a peer at
|
|
138
|
+
another address (a service bound to 0.0.0.0) is no local account, so its table is not read and it is served under
|
|
139
|
+
`refuse` and `strict` alike, with `peer_owner()` None."""
|
|
96
140
|
|
|
97
141
|
daemon_threads = True
|
|
142
|
+
peer_check = "refuse"
|
|
143
|
+
#: The reader of an accepted socket's peer; a test replaces it.
|
|
144
|
+
peer_reader = staticmethod(lambda sock: fabric_peer_owner.peer(sock))
|
|
145
|
+
|
|
146
|
+
def __init__(self, server_address: Any, handler: Any, bind_and_activate: bool = True, *,
|
|
147
|
+
peer_check: Optional[str] = None) -> None:
|
|
148
|
+
mode = self.peer_check if peer_check is None else peer_check
|
|
149
|
+
connection_refusal(None, mode) # refuses an unknown mode before the port is bound
|
|
150
|
+
self.peer_check = mode
|
|
151
|
+
self.peer_refusals = 0
|
|
152
|
+
self._peer = threading.local()
|
|
153
|
+
super().__init__(server_address, handler, bind_and_activate)
|
|
98
154
|
|
|
99
155
|
def server_bind(self) -> None:
|
|
100
156
|
socketserver.TCPServer.server_bind(self)
|
|
101
157
|
host, port = self.server_address[:2]
|
|
102
158
|
self.server_name, self.server_port = host, port
|
|
103
159
|
|
|
160
|
+
def peer_owner(self) -> Any:
|
|
161
|
+
"""The owner of the peer this thread's handler serves (None under `peer_check="off"`)."""
|
|
162
|
+
return getattr(self._peer, "owner", None)
|
|
163
|
+
|
|
164
|
+
def finish_request(self, request: Any, client_address: Any) -> None:
|
|
165
|
+
owner = None
|
|
166
|
+
if self.peer_check != "off" and is_loopback_peer(client_address):
|
|
167
|
+
try:
|
|
168
|
+
owner = self.peer_reader(request)
|
|
169
|
+
except Exception as exc: # a reader that fails is an owner nobody can tell, never a crash of the server
|
|
170
|
+
owner = fabric_peer_owner.Owner(fabric_peer_owner.UNKNOWN, None, None, "none",
|
|
171
|
+
"reader: %s" % type(exc).__name__)
|
|
172
|
+
reason = connection_refusal(owner, self.peer_check)
|
|
173
|
+
if reason is not None:
|
|
174
|
+
self.peer_refusals += 1
|
|
175
|
+
self.peer_refused(owner, reason)
|
|
176
|
+
return # process_request_thread closes the socket unread (shutdown_request)
|
|
177
|
+
self._peer.owner = owner
|
|
178
|
+
try:
|
|
179
|
+
super().finish_request(request, client_address)
|
|
180
|
+
finally:
|
|
181
|
+
self._peer.owner = None
|
|
182
|
+
|
|
183
|
+
def peer_refused(self, owner: Any, reason: str) -> None:
|
|
184
|
+
"""One line on stderr per refused connection; override to log it elsewhere. Never the peer's bytes."""
|
|
185
|
+
_sys.stderr.write("fabric: refused a loopback peer (%s, uid %s, pid %s) before reading it (DEC-0154)\n"
|
|
186
|
+
% (reason, getattr(owner, "uid", None), getattr(owner, "pid", None)))
|
|
187
|
+
|
|
104
188
|
|
|
105
189
|
def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
106
190
|
return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
|