@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +213 -0
- package/README.md +13 -2
- package/SECURITY.md +23 -8
- package/bin/fabric-agent-adapter.js +50 -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 +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
- 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 +107 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
- 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 +912 -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 +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
- 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 +25 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
- 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 +10 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
- 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 +24 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -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/agents-database.md +4 -4
- 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 +12 -4
- 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/resilience-and-autonomy.md +7 -1
- 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/references/where-agents-run.md +28 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/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 +26 -6
- 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 +21 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -115,12 +115,33 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
|
|
|
115
115
|
|
|
116
116
|
const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
|
|
117
117
|
|
|
118
|
+
// FAA-253: the well-known answer at `origin`, polled until this OS account's listener answers as serviceId.instance, as
|
|
119
|
+
// launchdInstall does (the pinned contract's Install row: every supervisor polls). Another account's listener, or one
|
|
120
|
+
// whose owner cannot be told, is never a successful install (DEC-0154 HA-2).
|
|
121
|
+
async function pollInstalled(origin, { serviceId, instance = 'default', timeout = 40, fetchWellKnown = wellKnownOf, sleep = pause, clock = Date.now } = {}) {
|
|
122
|
+
if (!serviceId) throw new ServiceError(`polling the install at ${origin} needs the serviceId it must answer as`);
|
|
123
|
+
const deadline = clock() + Math.max(0, timeout) * 1000;
|
|
124
|
+
let foreign = null;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const doc = await fetchWellKnown(origin);
|
|
127
|
+
foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
|
|
128
|
+
if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
129
|
+
if (clock() >= deadline) break;
|
|
130
|
+
await sleep(500);
|
|
131
|
+
}
|
|
132
|
+
if (foreign) throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
|
|
133
|
+
throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
/**
|
|
119
137
|
* Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
120
138
|
* The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
|
|
121
|
-
* new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
|
|
139
|
+
* new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on). With `origin`
|
|
140
|
+
* (and `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to the unit file once this
|
|
141
|
+
* account's listener answers (FAA-253); without `origin` it stays synchronous.
|
|
122
142
|
*/
|
|
123
|
-
export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false
|
|
143
|
+
export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false,
|
|
144
|
+
origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
|
|
124
145
|
if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
|
|
125
146
|
if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
|
|
126
147
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
@@ -128,10 +149,14 @@ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defau
|
|
|
128
149
|
const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
|
|
129
150
|
atomicWrite(file, text, 0o644);
|
|
130
151
|
checkRun(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
const started = !(state === 'disabled' && !forceEnable);
|
|
153
|
+
if (started) {
|
|
154
|
+
checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
|
|
155
|
+
checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
|
|
156
|
+
}
|
|
157
|
+
if (origin === undefined) return file;
|
|
158
|
+
// FAA-253: with an origin it resolves once this account's listener answers; a unit kept off is not polled.
|
|
159
|
+
return started ? pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock }).then(() => file) : Promise.resolve(file);
|
|
135
160
|
}
|
|
136
161
|
|
|
137
162
|
/** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
|
|
@@ -203,9 +228,12 @@ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Ena
|
|
|
203
228
|
|
|
204
229
|
/**
|
|
205
230
|
* Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
|
|
206
|
-
* is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
|
|
231
|
+
* is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`. With `origin` (and
|
|
232
|
+
* `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to it (FAA-253); a task kept off
|
|
233
|
+
* resolves to undefined, unpolled. Without `origin` it stays synchronous.
|
|
207
234
|
*/
|
|
208
|
-
export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false
|
|
235
|
+
export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false,
|
|
236
|
+
origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
|
|
209
237
|
if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
|
|
210
238
|
const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
|
|
211
239
|
const existed = query?.status === 0;
|
|
@@ -221,10 +249,11 @@ export function taskInstall(task, xmlText, { run = defaultRun, platform = proces
|
|
|
221
249
|
}
|
|
222
250
|
if (keepOff) {
|
|
223
251
|
checkRun(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
|
|
224
|
-
return;
|
|
252
|
+
return origin === undefined ? undefined : Promise.resolve(undefined);
|
|
225
253
|
}
|
|
226
254
|
if (existed) run(['schtasks', '/End', '/TN', task]);
|
|
227
255
|
checkRun(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
|
|
256
|
+
return origin === undefined ? undefined : pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock });
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
/** End the running task, then delete it. */
|
|
@@ -314,6 +343,8 @@ function guiDomain(platform, uid) {
|
|
|
314
343
|
/** Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278). */
|
|
315
344
|
export const SYSTEM_DAEMONS = '/Library/LaunchDaemons';
|
|
316
345
|
const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
346
|
+
/** The short-name rule in words, printed alike by both twins (FAA-286 item 13). */
|
|
347
|
+
export const USER_NAME_RULE = 'a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -';
|
|
317
348
|
|
|
318
349
|
/**
|
|
319
350
|
* The launchd domain a job lives in: `system` for a LaunchDaemon (`domain: 'system'`, or a plist in
|
|
@@ -322,6 +353,9 @@ const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
|
322
353
|
*/
|
|
323
354
|
export function launchdDomain(plistPath = null, { domain = null, platform = process.platform, uid } = {}) {
|
|
324
355
|
if (![null, undefined, 'gui', 'system'].includes(domain)) throw new ServiceError('domain must be gui or system');
|
|
356
|
+
if (plistPath !== null && plistPath !== undefined && /^(?:system|gui)(?:\/[0-9]*)?$/.test(String(plistPath))) {
|
|
357
|
+
throw new ServiceError(`${plistPath} is a launchd domain, not a plist path: pass { domain: '${String(plistPath) === 'system' ? 'system' : 'gui'}' } (FAA-286)`);
|
|
358
|
+
}
|
|
325
359
|
let which = domain;
|
|
326
360
|
if ((which === null || which === undefined) && plistPath !== null && plistPath !== undefined) {
|
|
327
361
|
const paths = platform === 'darwin' ? path.posix : path; // a macOS path, whatever host reads it
|
|
@@ -334,6 +368,34 @@ export function launchdDomain(plistPath = null, { domain = null, platform = proc
|
|
|
334
368
|
return guiDomain(platform, uid);
|
|
335
369
|
}
|
|
336
370
|
|
|
371
|
+
// A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd also
|
|
372
|
+
// loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root). Another
|
|
373
|
+
// folder is allowed for a system job, as the tests' and a staging install's are.
|
|
374
|
+
function pairProblem(plistPath, domain, platform) {
|
|
375
|
+
const paths = platform === 'darwin' ? path.posix : path;
|
|
376
|
+
const folder = paths.dirname(paths.resolve(String(plistPath)));
|
|
377
|
+
const parts = String(plistPath).split(/[\\/]+/); // the folder's own name, whatever separator a test's host path uses
|
|
378
|
+
if (domain === 'system' && String(parts[parts.length - 2] ?? '').toLowerCase() === 'launchagents') {
|
|
379
|
+
return `A LaunchDaemon's plist lives in ${SYSTEM_DAEMONS}: ${plistPath} is in a LaunchAgents folder, which launchd also loads into gui/<uid> at login, so the job would run twice.`;
|
|
380
|
+
}
|
|
381
|
+
if (domain === 'gui' && folder.toLowerCase() === SYSTEM_DAEMONS.toLowerCase()) {
|
|
382
|
+
return `${plistPath} is in ${SYSTEM_DAEMONS}, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in ~/Library/LaunchAgents.`;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A LaunchAgent of `label` in `userName`'s ~/Library/LaunchAgents, which would run a second copy at its login (FAA-286
|
|
388
|
+
// item 6); null when there is none or the home cannot be read.
|
|
389
|
+
function leftoverAgent(label, userName, run) {
|
|
390
|
+
const read = run(['dscl', '.', '-read', `/Users/${userName}`, 'NFSHomeDirectory']);
|
|
391
|
+
const home = read?.status === 0 ? /NFSHomeDirectory:\s*(\S.*?)\s*$/m.exec(String(read.stdout ?? ''))?.[1] : null;
|
|
392
|
+
if (!home) return null;
|
|
393
|
+
const folder = path.join(home, 'Library', 'LaunchAgents');
|
|
394
|
+
let names = [];
|
|
395
|
+
try { names = fs.readdirSync(folder).filter((n) => n.endsWith('.plist')).sort(); } catch { return null; }
|
|
396
|
+
return names.map((n) => path.join(folder, n)).find((file) => topKey(plistXml(file, run), 'Label') === label) ?? null;
|
|
397
|
+
}
|
|
398
|
+
|
|
337
399
|
// A system-domain job is installed and removed by root: refuse before anything is written (FAA-278).
|
|
338
400
|
function needRoot(domain, label, plistPath, install, euid) {
|
|
339
401
|
if (domain !== 'system' || euid === 0) return;
|
|
@@ -361,6 +423,9 @@ function decodeXml(text) {
|
|
|
361
423
|
function topStrings(text) {
|
|
362
424
|
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z][\w.:-]*)\s*[^>]*?(\/?)>|([^<]+)/g;
|
|
363
425
|
const values = Object.create(null);
|
|
426
|
+
// The open containers: only the root dict's own keys are read (FAA-286 item 10: a root <array> holding a
|
|
427
|
+
// <key>UserName</key> is no dict, as plistlib reads it).
|
|
428
|
+
const open = [];
|
|
364
429
|
let depth = 0;
|
|
365
430
|
let key = null;
|
|
366
431
|
let capture = null;
|
|
@@ -369,17 +434,21 @@ function topStrings(text) {
|
|
|
369
434
|
for (let m = token.exec(String(text ?? '')); m; m = token.exec(String(text ?? ''))) {
|
|
370
435
|
const [, cdata, closing, name, selfClosing, chars] = m;
|
|
371
436
|
if (cdata !== undefined || chars !== undefined) {
|
|
372
|
-
|
|
437
|
+
// every character reference, at any depth and in any value, is checked as expat checks it (FAA-286 item 10)
|
|
438
|
+
const decoded = chars !== undefined ? decodeXml(chars) : cdata;
|
|
439
|
+
if (capture) buffer += decoded;
|
|
373
440
|
} else if (name === 'dict' || name === 'array') {
|
|
374
441
|
if (selfClosing) {
|
|
375
442
|
if (depth === 1 && key !== null) { delete values[key]; key = null; }
|
|
376
443
|
} else if (closing) {
|
|
377
444
|
depth -= 1;
|
|
445
|
+
open.pop();
|
|
378
446
|
} else {
|
|
379
447
|
if (depth === 1 && key !== null) { delete values[key]; key = null; } // a container is this key's value
|
|
380
448
|
depth += 1;
|
|
449
|
+
open.push(name);
|
|
381
450
|
}
|
|
382
|
-
} else if (name && depth === 1) {
|
|
451
|
+
} else if (name && depth === 1 && open[0] === 'dict') {
|
|
383
452
|
if (selfClosing) {
|
|
384
453
|
if (key !== null) {
|
|
385
454
|
if (name === 'string') values[key] = '';
|
|
@@ -402,6 +471,7 @@ function topStrings(text) {
|
|
|
402
471
|
} catch {
|
|
403
472
|
return null;
|
|
404
473
|
}
|
|
474
|
+
if (key !== null || capture !== null || depth !== 0) return null; // a dangling <key> or an open element: unreadable
|
|
405
475
|
return values;
|
|
406
476
|
}
|
|
407
477
|
|
|
@@ -540,8 +610,8 @@ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
|
|
|
540
610
|
|
|
541
611
|
// Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
|
|
542
612
|
// which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
|
|
543
|
-
async function jobListens(pid, port, run = runProgram) {
|
|
544
|
-
if (
|
|
613
|
+
async function jobListens(pid, port, run = runProgram, platform = process.platform) {
|
|
614
|
+
if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
545
615
|
try {
|
|
546
616
|
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
547
617
|
return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
|
|
@@ -571,7 +641,11 @@ class JobAgent extends http.Agent {
|
|
|
571
641
|
this.ask = ask;
|
|
572
642
|
}
|
|
573
643
|
|
|
574
|
-
createConnection(options,
|
|
644
|
+
createConnection(options, given) {
|
|
645
|
+
// One answer per connection (FAA-286 item 12): an error during the async owner, lsof and ps checks would reach the
|
|
646
|
+
// callback a second time, as CheckedAgent's checkedSocket guards it.
|
|
647
|
+
let answered = false;
|
|
648
|
+
const callback = (err, socket) => { if (!answered) { answered = true; given(err, socket); } };
|
|
575
649
|
const port = Number(options.port);
|
|
576
650
|
const socket = net.connect({ host: '127.0.0.1', port, noDelay: true });
|
|
577
651
|
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), options.timeout ?? 10000);
|
|
@@ -585,14 +659,14 @@ class JobAgent extends http.Agent {
|
|
|
585
659
|
} catch (err) {
|
|
586
660
|
who = { kind: UNKNOWN, uid: null, pid: null, reason: `check: ${err?.name ?? 'Error'}` };
|
|
587
661
|
}
|
|
588
|
-
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run)) {
|
|
662
|
+
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run, this.ask.platform)) {
|
|
589
663
|
socket.destroy();
|
|
590
664
|
// The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
|
|
591
665
|
callback(new ListenerRefused(SERVER_NOT_OWN, `the LaunchDaemon's pid ${this.jobPid} is not shown listening on exactly 127.0.0.1:${port} by lsof (a wildcard is never vouched for); nothing was sent`));
|
|
592
666
|
return;
|
|
593
667
|
}
|
|
594
668
|
const proved = who.pid === this.jobPid || (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED)
|
|
595
|
-
&& await jobListens(this.jobPid, port, this.ask.run));
|
|
669
|
+
&& await jobListens(this.jobPid, port, this.ask.run, this.ask.platform));
|
|
596
670
|
if (!proved) {
|
|
597
671
|
socket.destroy();
|
|
598
672
|
callback(new ListenerRefused(SERVER_NOT_OWN, `the server on port ${port} is not the LaunchDaemon's pid ${this.jobPid} (${who.reason ?? who.kind}): another process holds the port, or the job's program starts the server as a child (start it with exec); nothing was sent`, who));
|
|
@@ -620,11 +694,11 @@ function loopbackPort(origin) {
|
|
|
620
694
|
}
|
|
621
695
|
|
|
622
696
|
// As wellKnownOf, for a LaunchDaemon: a loopback origin is read only when launchd's job `jobPid`, running as `jobUid`,
|
|
623
|
-
// accepted the connection.
|
|
624
|
-
export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
697
|
+
// accepted the connection. `ask` reaches the owner, lsof and ps readers (a test's fakes; `platform` for lsof's).
|
|
698
|
+
export async function jobWellKnownOf(origin, { jobPid, jobUid, ask = {} } = {}) {
|
|
625
699
|
const port = loopbackPort(origin);
|
|
626
700
|
if (port === null) throw new ServiceError(`A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid prove it; ${origin} is not.`);
|
|
627
|
-
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid }));
|
|
701
|
+
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid, ask }));
|
|
628
702
|
}
|
|
629
703
|
|
|
630
704
|
/**
|
|
@@ -643,9 +717,15 @@ export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
|
643
717
|
export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
|
|
644
718
|
run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), fetchWellKnown = wellKnownOf,
|
|
645
719
|
fetchJobWellKnown = jobWellKnownOf, sleep = pause } = {}) {
|
|
720
|
+
const pair = pairProblem(plistPath, domain, platform);
|
|
721
|
+
if (pair) throw new ServiceError(pair);
|
|
646
722
|
const dom = launchdDomain(plistPath, { domain, platform, uid });
|
|
647
723
|
launchdLabel(label);
|
|
648
724
|
if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
|
|
725
|
+
if (/^bplist/.test(Buffer.isBuffer(plistText) ? plistText.toString('latin1', 0, 6) : String(plistText ?? '').slice(0, 6))) {
|
|
726
|
+
// named as what it is, never as a missing UserName (FAA-286 item 7): the text launchdPlist writes is XML
|
|
727
|
+
throw new ServiceError('The plist is a binary plist: pass the XML text launchdPlist writes (plutil -convert xml1 converts one).');
|
|
728
|
+
}
|
|
649
729
|
needRoot(dom, label, plistPath, true, euid);
|
|
650
730
|
if (dom === 'system' && loopbackPort(origin) === null) {
|
|
651
731
|
throw new ServiceError(`A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by the job's pid and uid (FAA-278); ${origin} is not.`);
|
|
@@ -664,7 +744,7 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
664
744
|
}
|
|
665
745
|
}
|
|
666
746
|
if (dom === 'system' && named && !USER_NAME.test(named)) {
|
|
667
|
-
throw new ServiceError(`UserName ${named} is not a macOS short name (${
|
|
747
|
+
throw new ServiceError(`UserName ${named} is not a macOS short name (${USER_NAME_RULE}): launchd takes the account's short name.`);
|
|
668
748
|
}
|
|
669
749
|
const jobUid = named ? accountUid(named, run) : null;
|
|
670
750
|
if (dom === 'system' && named && jobUid === null) {
|
|
@@ -676,7 +756,12 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
676
756
|
if (dom === 'system' && !named) {
|
|
677
757
|
throw new ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with launchdPlist(..., { userName: <the account whose Fabric and agents call it> }).");
|
|
678
758
|
}
|
|
759
|
+
if (dom === 'system') {
|
|
760
|
+
const leftover = leftoverAgent(label, named, run);
|
|
761
|
+
if (leftover) throw new ServiceError(`${leftover} is a LaunchAgent of label ${label}: it would start a second copy at ${named}'s login beside this LaunchDaemon. Remove it first (launchdUninstall as ${named}; lifecycle reference, The system domain).`);
|
|
762
|
+
}
|
|
679
763
|
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
764
|
+
const existed = fs.existsSync(plistPath);
|
|
680
765
|
atomicWrite(plistPath, plistText, 0o644);
|
|
681
766
|
const lint = run(['plutil', '-lint', String(plistPath)]);
|
|
682
767
|
if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
|
|
@@ -696,6 +781,11 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
696
781
|
if (dom !== 'system' && /:\s*125:|Domain does not support|Could not find domain/.test(last)) {
|
|
697
782
|
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}. This user has no GUI login session (an SSH session, or a headless Mac after a FileVault restart), so ${dom} does not run jobs: remove the LaunchAgent this attempt wrote (launchdUninstall), then install it as a LaunchDaemon in the system domain (a plist in ${SYSTEM_DAEMONS} with UserName, as root; FAA-278).`);
|
|
698
783
|
}
|
|
784
|
+
if (dom === 'system') {
|
|
785
|
+
// a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
|
|
786
|
+
if (!existed) { try { fs.unlinkSync(plistPath); } catch { /* named below */ } }
|
|
787
|
+
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}; ${fs.existsSync(plistPath) ? `${plistPath} stays and loads at the next boot: remove it as root` : 'the plist this call wrote was removed'}`);
|
|
788
|
+
}
|
|
699
789
|
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
|
|
700
790
|
}
|
|
701
791
|
await sleep(1000);
|
|
@@ -751,6 +841,10 @@ export async function launchdUninstall(label, plistPath, { timeout = 30, purge =
|
|
|
751
841
|
needRoot(dom, label, plistPath, false, euid);
|
|
752
842
|
if (purge && dom === 'system') throw new ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then remove the data as that user.");
|
|
753
843
|
if (dom === 'system') refuseHeldLabel(label, plistPath, run); // a bootout would stop another plist's job (FAA-278)
|
|
844
|
+
const holds = fs.existsSync(plistPath) ? topKey(plistXml(plistPath, run), 'Label') : null;
|
|
845
|
+
if (holds !== null && holds !== label) { // FAA-286 item 9: booting out one job and unlinking another's plist
|
|
846
|
+
throw new ServiceError(`${plistPath} holds launchd label ${holds}, not ${label}: nothing was removed. Name the plist of ${label}, or uninstall ${holds} with its own label.`);
|
|
847
|
+
}
|
|
754
848
|
const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
|
|
755
849
|
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
756
850
|
const target = `${dom}/${label}`;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs
CHANGED
|
@@ -70,7 +70,14 @@ function byProject(receipts, first, last) {
|
|
|
70
70
|
const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
|
|
71
71
|
return [...projects.keys()].sort().map((key) => {
|
|
72
72
|
const { runs, ...row } = projects.get(key);
|
|
73
|
-
|
|
73
|
+
// A run's cost rounds as the project's does but never above its exact cost: each run and the project round apart,
|
|
74
|
+
// so many runs rounding up summed past the project and failed FAC-SEM-025 (board request f5753c5a).
|
|
75
|
+
const closeRun = (run) => {
|
|
76
|
+
const out = close(run);
|
|
77
|
+
if (out.costUsd !== null && out.costUsd > run.cost) out.costUsd = Math.round((out.costUsd - 0.000001) * 1e6) / 1e6;
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
const taskRuns = [...runs.keys()].sort().map((id) => closeRun(runs.get(id)));
|
|
74
81
|
return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
|
|
75
82
|
});
|
|
76
83
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -168,6 +168,7 @@ class Responder(SlotKeeper):
|
|
|
168
168
|
self.state = "idle"
|
|
169
169
|
self.wait_until = 0.0
|
|
170
170
|
self.stopping = False
|
|
171
|
+
self.shutting_down, self.draining = threading.Event(), None # shutdown began; it found work to drain (FAA-186)
|
|
171
172
|
self.claim_keys: Dict[str, str] = {}
|
|
172
173
|
self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
|
|
173
174
|
self._said: Dict[str, str] = {}
|
|
@@ -982,8 +983,8 @@ class Responder(SlotKeeper):
|
|
|
982
983
|
def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
|
|
983
984
|
"""Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
|
|
984
985
|
(its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
|
|
985
|
-
self.stopping = True
|
|
986
|
-
|
|
986
|
+
self.stopping, work = True, self.current
|
|
987
|
+
self.draining = work is not None; self.shutting_down.set() # noqa: E702 - published before it waits (FAA-186)
|
|
987
988
|
if work is not None and not work.done.wait(drain):
|
|
988
989
|
work.ctx.stopped.set()
|
|
989
990
|
if work is not None and work.done.is_set() and self.session is not None:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py
CHANGED
|
@@ -247,3 +247,5 @@ class SlotKeeper:
|
|
|
247
247
|
self._retry_later(capability, entries or [], exc)
|
|
248
248
|
if exc.code in SESSION_CODES:
|
|
249
249
|
raise
|
|
250
|
+
if exc.code == "idempotency_window_expired":
|
|
251
|
+
self._status() # the epoch moved: read it before the retry, which no held slot would do (FAA-186)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
`degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import bisect
|
|
7
8
|
import ipaddress
|
|
8
9
|
import re
|
|
10
|
+
import unicodedata
|
|
9
11
|
import urllib.parse
|
|
10
12
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
|
11
13
|
|
|
@@ -173,6 +175,92 @@ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
|
173
175
|
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
174
176
|
|
|
175
177
|
|
|
178
|
+
# FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
|
|
179
|
+
# the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
|
|
180
|
+
# test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
|
|
181
|
+
# code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
|
|
182
|
+
# classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
|
|
183
|
+
# Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
|
|
184
|
+
_IDNA_DISALLOWED = (
|
|
185
|
+
(0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
|
|
186
|
+
(0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
|
|
187
|
+
(0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
|
|
188
|
+
(0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
|
|
189
|
+
(0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
|
|
190
|
+
(0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
|
|
191
|
+
(0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
|
|
192
|
+
(0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
|
|
193
|
+
(0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
|
|
194
|
+
(0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
|
|
195
|
+
(0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
|
|
196
|
+
(0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
|
|
197
|
+
(0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
|
|
198
|
+
(0x100000, 0x10fffd),
|
|
199
|
+
)
|
|
200
|
+
_IDNA_IGNORED = (
|
|
201
|
+
(0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
|
|
202
|
+
(0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
|
|
203
|
+
)
|
|
204
|
+
_IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
|
|
205
|
+
_U32 = unicodedata.ucd_3_2_0
|
|
206
|
+
_RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
|
|
207
|
+
_JOINERS = "\u200c\u200d"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
|
|
211
|
+
i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
|
|
212
|
+
return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _known(c: str) -> bool:
|
|
216
|
+
return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _joiner_refused(label: str, i: int) -> bool:
|
|
220
|
+
"""CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
|
|
221
|
+
(`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
|
|
222
|
+
in its label has none for certain."""
|
|
223
|
+
return all(c.isascii() for j, c in enumerate(label) if j != i)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _bidi_refused(label: str) -> bool:
|
|
227
|
+
"""RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
|
|
228
|
+
not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
|
|
229
|
+
if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
|
|
230
|
+
return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
|
|
231
|
+
k = [_U32.bidirectional(c) for c in label]
|
|
232
|
+
if not any(x in ("R", "AL", "AN") for x in k):
|
|
233
|
+
return False
|
|
234
|
+
body = list(k)
|
|
235
|
+
while body and body[-1] == "NSM":
|
|
236
|
+
body.pop()
|
|
237
|
+
if not body or body[-1] not in ("R", "AL", "EN", "AN"):
|
|
238
|
+
return True
|
|
239
|
+
return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _idna_host(host: str) -> Optional[str]:
|
|
243
|
+
"""None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
|
|
244
|
+
as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
|
|
245
|
+
if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
|
|
246
|
+
return None
|
|
247
|
+
kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
|
|
248
|
+
if kept == "":
|
|
249
|
+
return None # nothing left: an empty host
|
|
250
|
+
if not all(_known(c) for c in kept):
|
|
251
|
+
return host # a code point this Python's Unicode does not know: the contextual rules are not read
|
|
252
|
+
mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
|
|
253
|
+
for label in _IDNA_DOTS.split(mapped):
|
|
254
|
+
first = label[:1]
|
|
255
|
+
if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
|
|
256
|
+
return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
|
|
257
|
+
if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
|
|
258
|
+
return None
|
|
259
|
+
if _bidi_refused(label):
|
|
260
|
+
return None
|
|
261
|
+
return _IDNA_DOTS.sub(".", mapped)
|
|
262
|
+
|
|
263
|
+
|
|
176
264
|
def _ipv4_number(part: str) -> Optional[int]:
|
|
177
265
|
if part == "":
|
|
178
266
|
return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
|
|
@@ -198,10 +286,16 @@ def _domain_ok(host: str) -> bool:
|
|
|
198
286
|
return False
|
|
199
287
|
if any(c in _FORBIDDEN_DOMAIN for c in host):
|
|
200
288
|
return False
|
|
201
|
-
# A non-ASCII host goes through UTS46 in WHATWG; this module
|
|
202
|
-
# valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
289
|
+
# A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
|
|
290
|
+
# the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
291
|
+
# (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
|
|
203
292
|
if any(ord(c) > 0x7f for c in host):
|
|
204
|
-
|
|
293
|
+
mapped = _idna_host(host)
|
|
294
|
+
if mapped is None:
|
|
295
|
+
return False
|
|
296
|
+
if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
|
|
297
|
+
return True
|
|
298
|
+
host = mapped
|
|
205
299
|
labels = host.split(".")
|
|
206
300
|
if labels and labels[-1] == "" and len(labels) > 1:
|
|
207
301
|
labels = labels[:-1]
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py
CHANGED
|
@@ -13,7 +13,8 @@ not re-export this module, so a service that does not run per context keeps the
|
|
|
13
13
|
An organization's instance whose context the record does not pin (``organizations``, DEC-0127) still starts: the helper
|
|
14
14
|
checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
|
|
15
15
|
``fabric_estate.hub_key_certified`` reads a context's hub keys only while the record pins that organization (DL-14a,
|
|
16
|
-
DEC-0128), so the service passes ``
|
|
16
|
+
DEC-0128), so the service passes ``context_gate_options(info, record)`` (its context and its pin's issuer, FAA-150) to its
|
|
17
|
+
delegation gate. The contract lets Fabric
|
|
17
18
|
write ``organizations`` only once every reader is pinned at or after DEC-0127, so a refusal at start would stop every
|
|
18
19
|
organization instance on today's records."""
|
|
19
20
|
from __future__ import annotations
|
|
@@ -33,7 +34,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
33
34
|
from fabric_base import ServiceError, _INSTANCE # noqa: E402
|
|
34
35
|
from fabric_descriptor import descriptor_path # noqa: E402
|
|
35
36
|
from fabric_estate import parse_context # noqa: E402
|
|
36
|
-
from fabric_lock import InstanceLock, hold_single_instance # noqa: E402
|
|
37
|
+
from fabric_lock import (InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, hold_single_instance, # noqa: E402
|
|
38
|
+
lock_wait_budget_seconds, supervisor_marker)
|
|
37
39
|
from fabric_paths import atomic_write, instance_dirs # noqa: E402
|
|
38
40
|
from fabric_share import instance_share_grant # noqa: E402
|
|
39
41
|
from fabric_time import clock_ms # noqa: E402
|
|
@@ -125,9 +127,49 @@ def context_instance(service_id: str, context: Any, *, record: Optional[Dict[str
|
|
|
125
127
|
return answer
|
|
126
128
|
|
|
127
129
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
def context_gate_options(info: Any, record: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
131
|
+
"""What ``delegation_gate`` takes from this instance's context (FAA-150): ``estate_context``, and for an organization
|
|
132
|
+
whose context ``record`` pins, ``issuer``: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance
|
|
133
|
+
refuses a token the personal hub key signed without wiring either by hand. ``info`` is ``context_instance``'s answer
|
|
134
|
+
(or ``hold_context_instance``'s); ``record`` the verified estate revision applied. Spread it after the service's own
|
|
135
|
+
estate issuer: ``delegation_gate(..., **{"issuer": estate_issuer, **context_gate_options(info, record)})``. Raises
|
|
136
|
+
ContextRefused (``context-invalid``) for an answer with no context."""
|
|
137
|
+
context = info.get("context") if isinstance(info, dict) else None
|
|
138
|
+
parsed = parse_context(context)
|
|
139
|
+
if parsed is None:
|
|
140
|
+
raise ContextRefused("context-invalid")
|
|
141
|
+
options: Dict[str, Any] = {"estate_context": context}
|
|
142
|
+
pins = record.get("organizations") if isinstance(record, dict) else None
|
|
143
|
+
if parsed["kind"] == "org" and isinstance(pins, list):
|
|
144
|
+
for pin in pins:
|
|
145
|
+
if isinstance(pin, dict) and pin.get("org") == context and isinstance(pin.get("iss"), str):
|
|
146
|
+
options["issuer"] = pin["iss"]
|
|
147
|
+
break
|
|
148
|
+
return options
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _refuse(service_id: Any, context: Any, code: str, wait_seconds: Optional[float] = 0.0, *,
|
|
152
|
+
sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
|
|
153
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> None:
|
|
154
|
+
"""One sentence naming the code, then exit 78. Under a supervisor that restarts on exit (launchd's KeepAlive with
|
|
155
|
+
ThrottleInterval 10, systemd's Restart=always) an exit at once is a respawn every few seconds, forever (FAA-279);
|
|
156
|
+
so a supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing, and
|
|
157
|
+
exits 78 when it runs out: a few restarts an hour. ``wait_seconds`` overrides the choice as the lock's does."""
|
|
158
|
+
if wait_seconds is None:
|
|
159
|
+
wait_seconds = lock_wait_budget_seconds(supervisor_marker(env, argv))
|
|
160
|
+
wait_seconds = max(0.0, wait_seconds)
|
|
161
|
+
tail = " Waiting %.0f s before exiting, so its supervisor does not restart it every few seconds." % wait_seconds \
|
|
162
|
+
if wait_seconds > 0 else ""
|
|
163
|
+
print("%s will not run for %s (%s): %s.%s" % (service_id, _shown(context), code, _REASONS[code], tail),
|
|
164
|
+
file=sys.stderr, flush=True)
|
|
165
|
+
deadline = clock() + wait_seconds
|
|
166
|
+
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
167
|
+
while True:
|
|
168
|
+
remaining = deadline - clock()
|
|
169
|
+
if remaining <= 0:
|
|
170
|
+
raise SystemExit(EXIT_CONTEXT_REFUSED)
|
|
171
|
+
sleep(min(delay, remaining))
|
|
172
|
+
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
131
173
|
|
|
132
174
|
|
|
133
175
|
def _marker_matches(path: Path, expected: Dict[str, str]) -> Optional[bool]:
|
|
@@ -152,7 +194,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
|
|
|
152
194
|
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
|
|
153
195
|
services_directory: Optional[Path] = None) -> Tuple[Dict[str, Any], InstanceLock]:
|
|
154
196
|
"""Run as ``context``'s one instance, before any side effect (brief D17): ``context_instance`` (a refusal prints one
|
|
155
|
-
sentence naming the code and exits 78, nothing created); then ``hold_single_instance`` on the instance's data
|
|
197
|
+
sentence naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then ``hold_single_instance`` on the instance's data
|
|
156
198
|
folder (its markers, its supervised wait, its exit 75); then the marker ``<data>/fabric-context.json``: absent, it
|
|
157
199
|
is written (0600, atomic); equal, it is kept; another context or instance, or unreadable, releases the lock and
|
|
158
200
|
exits 78 (``context-mismatch``). Returns the instance and the held lock."""
|
|
@@ -160,7 +202,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
|
|
|
160
202
|
info = context_instance(service_id, context, record=record, machine_id=machine_id, agent_ref=agent_ref, at=at,
|
|
161
203
|
personal_instance=personal_instance, services_directory=services_directory)
|
|
162
204
|
except ContextRefused as refused:
|
|
163
|
-
_refuse(service_id, context, refused.code)
|
|
205
|
+
_refuse(service_id, context, refused.code, wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
164
206
|
lock = hold_single_instance(info["dirs"]["data"], wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
165
207
|
expected = {"service": service_id, "instance": info["instance"], "context": context}
|
|
166
208
|
marker = Path(info["dirs"]["data"]) / CONTEXT_MARKER
|
|
@@ -173,5 +215,5 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
|
|
|
173
215
|
raise
|
|
174
216
|
if matches is False:
|
|
175
217
|
lock.release()
|
|
176
|
-
_refuse(service_id, context, "context-mismatch")
|
|
218
|
+
_refuse(service_id, context, "context-mismatch", wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
177
219
|
return info, lock
|