@passioncode-ai/fabric-agent-adapter 0.22.0 → 0.24.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.
Files changed (23) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +64 -14
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +9 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -4
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -45
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -62
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +10 -3
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -52,8 +52,13 @@ const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
52
52
  const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
53
53
  const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
54
54
  // DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
55
- const LOCAL_PATH = /^(~[\\/]|\/(?!\/)|[A-Za-z]:[\\/])/;
56
- export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
55
+ // DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
56
+ const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
57
+ // DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app a
58
+ // person installs and starts; its descriptor says it is installed, never that it runs).
59
+ export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node', 'person', 'none']);
60
+ /** Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069). */
61
+ const NOT_LAUNCHD = Object.freeze(['systemd', 'task-scheduler', 'node', 'person']);
57
62
  const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
58
63
  const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
59
64
  const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
@@ -63,7 +68,8 @@ export const DEFAULT_EXIT_TIMEOUT = 15;
63
68
  export class ServiceError extends Error {}
64
69
  export class AlreadyRunning extends ServiceError {
65
70
  constructor(holderPid, lockPath) {
66
- super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'another process'} holds ${lockPath}).`);
71
+ // FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
72
+ super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'an unknown process'} holds ${lockPath}).`);
67
73
  this.holderPid = holderPid;
68
74
  this.lockPath = lockPath;
69
75
  }
@@ -209,25 +215,92 @@ function readPid(file) {
209
215
  }
210
216
  }
211
217
 
218
+ /** The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node. */
219
+ export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node']);
220
+ /** The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action). */
221
+ export const SUPERVISOR_ARG = '--fabric-supervisor';
222
+
223
+ /**
224
+ * The marker values an argument vector carries after the executable and before `--`. A bare `--fabric-supervisor`
225
+ * followed by nothing, by `--` or by another `--…` flag is a marker with an empty value and consumes nothing.
226
+ */
227
+ function markerArguments(argv) {
228
+ const values = [];
229
+ if (!Array.isArray(argv)) return values;
230
+ for (let index = 1; index < argv.length; index += 1) {
231
+ const item = argv[index];
232
+ if (item === '--') break;
233
+ if (item === SUPERVISOR_ARG) {
234
+ const next = argv[index + 1];
235
+ if (typeof next === 'string' && !next.startsWith('--')) {
236
+ values.push(next);
237
+ index += 1;
238
+ } else {
239
+ values.push('');
240
+ }
241
+ } else if (typeof item === 'string' && item.startsWith(`${SUPERVISOR_ARG}=`)) {
242
+ values.push(item.slice(SUPERVISOR_ARG.length + 1));
243
+ }
244
+ }
245
+ return values;
246
+ }
247
+
248
+ /**
249
+ * How this copy was started (DEC-0057), from `FABRIC_SERVICE_SUPERVISOR=<manager>` and `--fabric-supervisor <manager>` /
250
+ * `--fabric-supervisor=<manager>` (`env` and `argv` default to this process's; `argv[0]` is the executable or script):
251
+ * `{kind: 'supervised', manager, forms}` when every marker present names the same manager of SUPERVISORS;
252
+ * `{kind: 'conflict', values, forms}` when they disagree (an empty or bare argument beside a variable disagrees with it):
253
+ * the copy exits 75 at once, before it takes the lock; `{kind: 'manual', forms, unreadable?}` with no marker (only an
254
+ * empty variable counts as none) or one that names no manager. Never inferred from the parent process. The contract's
255
+ * `supervisorMarker`.
256
+ */
257
+ export function supervisorMarker({ env = process.env, argv = process.argv.slice(1) } = {}) {
258
+ const variable = env?.[SUPERVISOR_ENV];
259
+ const fromEnv = typeof variable === 'string' && variable !== '' ? [variable] : [];
260
+ const fromArgs = markerArguments(argv);
261
+ const forms = [...(fromEnv.length ? ['environment'] : []), ...(fromArgs.length ? ['argument'] : [])];
262
+ const values = [...new Set([...fromEnv, ...fromArgs])];
263
+ if (values.length > 1) return { kind: 'conflict', values, forms };
264
+ if (values.length === 0) return { kind: 'manual', forms };
265
+ return SUPERVISORS.includes(values[0]) ? { kind: 'supervised', manager: values[0], forms } : { kind: 'manual', unreadable: values[0], forms };
266
+ }
267
+
268
+ /** How long a copy started so may wait for the lock, in seconds: 300 when supervised, else not at all. */
269
+ export const lockWaitBudgetSeconds = (marker) => (marker?.kind === 'supervised' ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
270
+
271
+ /** The one sentence a copy whose markers disagree exits with, naming the markers present. */
272
+ export function markerConflictMessage(marker) {
273
+ const shown = marker.values.map((v) => JSON.stringify(v)).join(', ');
274
+ return marker.forms.length === 2
275
+ ? `${SUPERVISOR_ENV} and ${SUPERVISOR_ARG} disagree (${shown}); not starting unsupervised.`
276
+ : `${SUPERVISOR_ARG} is given with different values (${shown}); not starting unsupervised.`;
277
+ }
278
+
279
+ /** True when a supervisor that restarts on exit started this process and said so with the marker. */
280
+ export const supervised = (start) => supervisorMarker(start).kind === 'supervised';
212
281
  /** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
213
- export const supervisedByLaunchd = () => process.env[SUPERVISOR_ENV] === 'launchd';
214
- export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler']);
215
- /** True when a supervisor that restarts on exit started this process and said so (launchd, systemd). */
216
- export const supervised = () => SUPERVISORS.includes(process.env[SUPERVISOR_ENV]);
282
+ export const supervisedByLaunchd = (start) => supervisorMarker(start).manager === 'launchd';
217
283
 
218
284
  const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
219
285
 
220
286
  /**
221
- * Take the lock or exit 75 with one sentence naming the holder. Started by hand, a held lock exits
222
- * 75 at once. Under launchd (KeepAlive) an immediate exit is a respawn every ThrottleInterval,
287
+ * Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078). Markers that
288
+ * disagree exit 75 before the lock. Started by hand, a held lock exits 75 at once. Under a
289
+ * supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
223
290
  * forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
224
291
  * LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
225
292
  * the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
226
293
  */
227
294
  export async function holdSingleInstance(dataDir, {
228
- waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
295
+ waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
229
296
  } = {}) {
230
- const wait = waitSeconds ?? (supervised() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
297
+ // DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
298
+ const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
299
+ if (marker.kind === 'conflict') {
300
+ process.stderr.write(`${markerConflictMessage(marker)}\n`);
301
+ return exit(EXIT_ALREADY_RUNNING);
302
+ }
303
+ const wait = waitSeconds ?? lockWaitBudgetSeconds(marker);
231
304
  const deadline = clock() + Math.max(0, wait);
232
305
  let delay = LOCK_BACKOFF_FIRST_SECONDS;
233
306
  let announced = false;
@@ -263,9 +336,8 @@ export function ensureToken(file) {
263
336
 
264
337
  /**
265
338
  * Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
266
- * service.md "Windows token files" (DEC-0032, DEC-0033): a regular file whose real path is in the
267
- * profile, owned by the current user, SYSTEM or Administrators, every granting ACE naming one of
268
- * those three; a refusal names the SID, never the contents.
339
+ * service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057): windowsTokenAclProblem over the
340
+ * file's flags, owner and ACEs; a refusal names the SID, never the contents.
269
341
  */
270
342
  export function readToken(file) {
271
343
  const info = fs.lstatSync(file);
@@ -286,30 +358,68 @@ export function readToken(file) {
286
358
  // Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
287
359
  // and icacls writes it; both get the path through the environment, never argv, and print no contents.
288
360
 
361
+ export const SYSTEM_SID = 'S-1-5-18';
362
+ export const ADMINISTRATORS_SID = 'S-1-5-32-544';
363
+ /** A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each. */
364
+ const SID_TEXT = /^S-1-\d{1,15}(-\d{1,10}){0,15}$/i;
365
+ /** A readable SID in upper case (SIDs compare case-insensitively on their S), or undefined. */
366
+ const sidOf = (value) => (typeof value === 'string' && SID_TEXT.test(value) && value.split('-').slice(3).every((part) => Number(part) <= 0xffffffff) ? value.toUpperCase() : undefined);
367
+
289
368
  /**
290
- * service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the current
291
- * user, SYSTEM or Administrators (DEC-0033), and every SID an allow ACE grants anything to is one of
292
- * those three. Deny ACEs are not passed in: they do not change the decision.
369
+ * service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read: `file` is
370
+ * `{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}`, the mask the ACE's unsigned
371
+ * 32-bit access mask. The first refusal `{problem, at, sid?, message}`, or null when the file may be read: a regular
372
+ * file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every allow ACE with a
373
+ * non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything unreadable refused.
374
+ * The contract's `windowsTokenAclProblem`; a refusal names the SID, never the contents.
293
375
  */
294
- export function tokenAclProblem(file, owner, granting, user) {
295
- if (owner !== user && !WINDOWS_TRUSTED.includes(owner)) {
296
- return `Token file ${file} is owned by ${owner || 'nobody'}, not by this user (${user}), SYSTEM or Administrators.`;
297
- }
298
- for (const sid of granting) {
299
- if (sid !== user && !WINDOWS_TRUSTED.includes(sid)) return `Token file ${file} grants access to ${sid}; only this user, SYSTEM and Administrators may hold any.`;
376
+ export function windowsTokenAclProblem(file, currentUserSid) {
377
+ const refuse = (problem, at, message, sid) => (sid === undefined ? { problem, at, message } : { problem, at, sid, message });
378
+ const user = sidOf(currentUserSid);
379
+ if (user === undefined) return refuse('unreadable', '/currentUserSid', "the current user's SID is unreadable");
380
+ if (typeof file !== 'object' || file === null || Array.isArray(file)) return refuse('unreadable', '/file', "the token file's metadata is unreadable");
381
+ for (const flag of ['isRegularFile', 'isReparsePoint', 'realPathInProfile']) {
382
+ if (typeof file[flag] !== 'boolean') return refuse('unreadable', `/file/${flag}`, `${flag} is unreadable`);
383
+ }
384
+ if (file.isReparsePoint) return refuse('reparse-point', '/file/isReparsePoint', 'the token file is a reparse point (a symlink or a junction)');
385
+ if (!file.isRegularFile) return refuse('not-regular-file', '/file/isRegularFile', 'the token file is not a regular file');
386
+ if (!file.realPathInProfile) return refuse('outside-profile', '/file/realPathInProfile', "the token file's real path lies outside the user's profile");
387
+ const trusted = new Set([user, SYSTEM_SID, ADMINISTRATORS_SID]);
388
+ const owner = sidOf(file.ownerSid);
389
+ if (owner === undefined) return refuse('unreadable', '/file/ownerSid', 'the owner SID is unreadable');
390
+ if (!trusted.has(owner)) return refuse('owner', '/file/ownerSid', `the token file is owned by ${owner}, not by the current user, SYSTEM or Administrators`, owner);
391
+ if (!Array.isArray(file.aces)) return refuse('unreadable', '/file/aces', 'the ACL is unreadable');
392
+ for (const [index, ace] of file.aces.entries()) {
393
+ const at = `/file/aces/${index}`;
394
+ if (typeof ace !== 'object' || ace === null || Array.isArray(ace)) return refuse('unreadable', at, `ACE ${index} is unreadable`);
395
+ if (typeof ace.type !== 'string') return refuse('unreadable', `${at}/type`, `ACE ${index} has an unreadable type`);
396
+ const trustee = sidOf(ace.sid);
397
+ if (trustee === undefined) return refuse('unreadable', `${at}/sid`, `ACE ${index} names an unreadable SID`);
398
+ if (typeof ace.mask !== 'number' || !Number.isInteger(ace.mask) || ace.mask < 0 || ace.mask > 0xffffffff) return refuse('unreadable', `${at}/mask`, `ACE ${index} has an unreadable access mask`);
399
+ if (ace.type === 'deny') continue;
400
+ if (ace.type !== 'allow') return refuse('ace-type', `${at}/type`, `ACE ${index} for ${trustee} is of a type the rule does not read`, trustee);
401
+ if (ace.mask === 0) continue;
402
+ if (!trusted.has(trustee)) return refuse('ace-sid', `${at}/sid`, `the token file grants access to ${trustee}; only the current user, SYSTEM and Administrators may hold any`, trustee);
300
403
  }
301
404
  return null;
302
405
  }
303
406
 
407
+ /** windowsTokenAclProblem as one sentence naming the file and the refusal, or null. */
408
+ export function tokenFileProblem(file, metadata, user) {
409
+ const problem = windowsTokenAclProblem(metadata, user);
410
+ return problem ? `Token file ${file} refused: ${problem.message}.` : null;
411
+ }
412
+
413
+ // The raw descriptor, through .NET: every ACE's type, its trustee's SID and its access mask as read (a signed int;
414
+ // parseAclReport converts it to the unsigned mask). An inherit-only ACE applies to children only and is left out.
304
415
  const ACL_SCRIPT = [
305
416
  "$ErrorActionPreference = 'Stop'",
306
- '$sid = [System.Security.Principal.SecurityIdentifier]',
307
417
  '$acl = [System.IO.File]::GetAccessControl($env:FABRIC_ACL_PATH)',
308
- "$null_dacl = $acl.GetSecurityDescriptorSddlForm('Access') -match 'NO_ACCESS_CONTROL' -or $acl.GetSecurityDescriptorSddlForm('Access') -eq ''",
309
- // InheritOnly (2) applies to children, not to the file itself.
310
- "$granting = @($acl.GetAccessRules($true, $true, $sid) | Where-Object { $_.AccessControlType -eq 'Allow' -and -not ([int]$_.PropagationFlags -band 2) } | ForEach-Object { $_.IdentityReference.Value })",
418
+ "$raw = New-Object System.Security.AccessControl.RawSecurityDescriptor($acl.GetSecurityDescriptorSddlForm('Owner, Access'))",
419
+ '$dacl = $raw.DiscretionaryAcl',
420
+ "$aces = @(if ($dacl) { $dacl | Where-Object { -not ([int]$_.AceFlags -band 8) } | ForEach-Object { @{ type = [string]$_.AceType; sid = $(if ($_.SecurityIdentifier) { $_.SecurityIdentifier.Value } else { '' }); mask = [int64]$_.AccessMask } } })",
311
421
  '$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
312
- '[Console]::Out.Write((ConvertTo-Json -Compress @{ owner = $acl.GetOwner($sid).Value; granting = $granting; nullDacl = [bool]$null_dacl; user = $user }))',
422
+ '[Console]::Out.Write((ConvertTo-Json -Compress -Depth 4 @{ owner = $raw.Owner.Value; aces = $aces; nullDacl = [bool](-not $dacl); user = $user }))',
313
423
  ].join('; ');
314
424
 
315
425
  function windowsHelper(command, args, extraEnv = {}) {
@@ -323,14 +433,24 @@ function windowsHelper(command, args, extraEnv = {}) {
323
433
  return result.stdout;
324
434
  }
325
435
 
326
- /** The ACL report PowerShell prints, reduced to (owner, granting SIDs, current user). */
436
+ /** .NET's AceType names as the rule's ACE type: allow and deny are read, every other type is refused (DEC-0057). */
437
+ const ACE_TYPES = { AccessAllowed: 'allow', AccessDenied: 'deny', AccessAllowedObject: 'allow-object', AccessDeniedObject: 'deny-object',
438
+ AccessAllowedCallback: 'allow-callback', AccessDeniedCallback: 'deny-callback', AccessAllowedCallbackObject: 'allow-object-callback',
439
+ AccessDeniedCallbackObject: 'deny-object-callback' };
440
+
441
+ /**
442
+ * The ACL report PowerShell prints, as (owner SID, ACEs `{type, sid, mask}`, current user). The mask arrives signed
443
+ * (GENERIC_READ 0x80000000 is negative in .NET) and is read as the unsigned 32-bit mask; a NULL DACL grants everyone
444
+ * everything and is read as an allow ACE for Everyone (S-1-1-0).
445
+ */
327
446
  export function parseAclReport(text) {
328
447
  const report = JSON.parse(text);
329
- const granting = report.nullDacl ? ['S-1-1-0'] : [].concat(report.granting ?? []);
330
- return { owner: report.owner ?? '', granting, user: report.user };
448
+ const aces = report.nullDacl ? [{ type: 'allow', sid: 'S-1-1-0', mask: 0x1f01ff }]
449
+ : [].concat(report.aces ?? []).map((ace) => ({ type: ACE_TYPES[ace.type] ?? String(ace.type), sid: ace.sid, mask: Number.isInteger(ace.mask) ? ace.mask >>> 0 : ace.mask }));
450
+ return { owner: report.owner ?? '', aces, user: report.user };
331
451
  }
332
452
 
333
- /** (owner SID, SIDs of every allow ACE on the file itself, the current user's SID). */
453
+ /** (owner SID, the ACEs on the file itself, the current user's SID). */
334
454
  export function windowsAcl(file) {
335
455
  return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
336
456
  }
@@ -356,13 +476,14 @@ export function windowsProtect(target, { folder }) {
356
476
  }
357
477
 
358
478
  function windowsTokenProblem(file, info) {
359
- if (!info.isFile()) return `Token file ${file} is not a regular file (a link or junction); refusing it.`;
360
479
  const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
361
480
  const real = fs.realpathSync.native(file).toLowerCase();
362
481
  const inside = path.win32.relative(profile, real);
363
- if (!inside || inside.startsWith('..') || path.win32.isAbsolute(inside)) return `Token file ${file} lies outside the user's profile; refusing it.`;
364
- const { owner, granting, user } = windowsAcl(file);
365
- return tokenAclProblem(file, owner, granting, user);
482
+ const { owner, aces, user } = windowsAcl(file);
483
+ return tokenFileProblem(file, {
484
+ isRegularFile: info.isFile(), isReparsePoint: info.isSymbolicLink(),
485
+ realPathInProfile: Boolean(inside) && !inside.startsWith('..') && !path.win32.isAbsolute(inside), ownerSid: owner, aces,
486
+ }, user);
366
487
  }
367
488
 
368
489
  export function tokenMatches(presented, token, scheme = 'Bearer') {
@@ -404,7 +525,9 @@ export function validateDescriptor(d) {
404
525
  const problems = [];
405
526
  const remote = placementOf(d) === 'remote';
406
527
  if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
407
- const required = ['protocol', 'id', 'instance', 'name', 'origin', 'auth', 'lifecycle', 'installedAt', 'installedBy'];
528
+ // DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
529
+ const app = d.lifecycle?.manager === 'person';
530
+ const required = ['protocol', 'id', 'instance', 'name', ...(app ? [] : ['origin']), 'auth', 'lifecycle', 'installedAt', 'installedBy'];
408
531
  for (const key of remote ? required : [...required, 'paths']) {
409
532
  if (!(key in d)) problems.push(`missing ${key}`);
410
533
  }
@@ -413,7 +536,7 @@ export function validateDescriptor(d) {
413
536
  if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
414
537
  if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
415
538
  if (remote) problems.push(...remoteOriginProblems(d.origin));
416
- else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
539
+ else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
417
540
  if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
418
541
  else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
419
542
  for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
@@ -426,11 +549,19 @@ export function validateDescriptor(d) {
426
549
  }
427
550
  const life = d.lifecycle ?? {};
428
551
  const manager = life.manager;
429
- if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
552
+ if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none');
553
+ // DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
554
+ if (manager === 'person' && life.app === undefined) problems.push("a person's app declares lifecycle.app, the installed app");
555
+ if (life.app !== undefined && manager !== 'person') problems.push('lifecycle.app belongs to manager person only');
556
+ if (life.app !== undefined && !LOCAL_PATH.test(String(life.app))) problems.push('lifecycle.app must be a local path');
430
557
  if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
431
558
  problems.push('a launchd service declares label and plist');
432
559
  }
433
560
  if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
561
+ // DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
562
+ for (const field of ['label', 'plist']) {
563
+ if (life[field] !== undefined && NOT_LAUNCHD.includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
564
+ }
434
565
  // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
435
566
  for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
436
567
  if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
@@ -444,6 +575,7 @@ export function validateDescriptor(d) {
444
575
  }
445
576
  if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
446
577
  }
578
+ problems.push(...descriptorMemberProblems(d));
447
579
  for (const [name, argv] of Object.entries(d.commands ?? {})) {
448
580
  if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
449
581
  else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
@@ -453,6 +585,127 @@ export function validateDescriptor(d) {
453
585
  }
454
586
 
455
587
  /** DEC-0019: `local` unless the descriptor says `remote`. */
588
+ // --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
589
+
590
+ /** The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053). */
591
+ export const PEER_DATA_CLASSES = Object.freeze(['public', 'internal', 'confidential', 'restricted']);
592
+ /** The kind of a runner's sign-in, never a credential's value (DEC-0061). */
593
+ export const CREDENTIAL_KINDS = Object.freeze(['subscription', 'key', 'platform-metered', 'none']);
594
+ /** A subscription runs only in its vendor's runner (DEC-0046): runner kind → login. */
595
+ export const SUBSCRIPTION_RUNNERS = Object.freeze({ 'claude-code': 'claude', 'kimi-code': 'kimi', codex: 'chatgpt' });
596
+ const PEER_ENDPOINT = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
597
+ const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
598
+ const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
599
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
600
+ const text = (v, low = 1, high = 200) => typeof v === 'string' && v.length >= low && v.length <= high;
601
+ /** A JSON Pointer token (RFC 6901 §4). */
602
+ const pointer = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
603
+
604
+ /**
605
+ * DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with FAC-SEM-085
606
+ * (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`, named by its
607
+ * place, never its value).
608
+ */
609
+ export function descriptorMemberProblems(d) {
610
+ const problems = [];
611
+ const life = isObj(d.lifecycle) ? d.lifecycle : {};
612
+ const by = d.installedBy;
613
+ const places = [];
614
+ if (typeof by === 'string') {
615
+ if (!text(by)) problems.push('installedBy is the installer and its version, 1 to 200 characters');
616
+ places.push(['/installedBy', by]);
617
+ } else if (isObj(by)) {
618
+ for (const k of Object.keys(by)) if (!['by', 'role', 'tool'].includes(k)) problems.push(`installedBy has no member ${k}`);
619
+ if (!['person', 'node', 'organization'].includes(by.by)) problems.push('installedBy.by is person, node or organization');
620
+ if (by.by === 'person' && !['operator', 'administrator'].includes(by.role)) problems.push('installedBy.role is operator or administrator for a person');
621
+ if (by.by !== 'person' && 'role' in by) problems.push('installedBy.role belongs to a person only');
622
+ if (!text(by.tool)) problems.push('installedBy.tool is the installer and its version, 1 to 200 characters');
623
+ for (const [k, v] of Object.entries(by)) places.push([`/installedBy/${pointer(k)}`, v]);
624
+ } else problems.push("installedBy is the installer's name or {by, role?, tool}");
625
+ if (life.manager === 'person' && !(isObj(by) && by.by === 'person')) problems.push("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)");
626
+ let runner = d.runner;
627
+ if ('runner' in d) {
628
+ if (!isObj(runner)) { problems.push('runner is {kind?, credentialKind}'); runner = {}; }
629
+ for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
630
+ if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
631
+ if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
632
+ if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
633
+ for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
634
+ }
635
+ if ('peer' in d) {
636
+ let peer = d.peer;
637
+ if (!isObj(peer)) { problems.push('peer is {endpoint, custody, residency, maxDataClass}'); peer = {}; }
638
+ for (const k of Object.keys(peer)) if (!['endpoint', 'custody', 'residency', 'maxDataClass'].includes(k)) problems.push(`peer has no member ${k}`);
639
+ const m = typeof peer.endpoint === 'string' && peer.endpoint.length <= 2048 ? PEER_ENDPOINT.exec(peer.endpoint) : null;
640
+ if (!m) problems.push('peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal');
641
+ else if (RESERVED_HOST.test(m[1])) problems.push(`FAC-SEM-085: a service offered to others cannot be reached at the reserved name ${m[1]}`);
642
+ if (!['organization-managed', 'personal'].includes(peer.custody)) problems.push('peer.custody is organization-managed or personal');
643
+ const r = peer.residency;
644
+ if (!Array.isArray(r) || r.length < 1 || r.length > 32 || new Set(r.map((x) => JSON.stringify(x))).size !== r.length || !r.every((x) => typeof x === 'string' && RESIDENCY.test(x))) {
645
+ problems.push('peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code');
646
+ }
647
+ if (!PEER_DATA_CLASSES.includes(peer.maxDataClass)) problems.push('peer.maxDataClass is public, internal, confidential or restricted');
648
+ if (!('runner' in d)) problems.push('a service offered to others declares runner (DEC-0061)');
649
+ else if (isObj(runner) && runner.credentialKind === 'subscription') problems.push('a service offered to others never runs on a subscription (DEC-0061)');
650
+ }
651
+ for (const [place, value] of places) {
652
+ if (typeof value === 'string' && secretArgument(value)) problems.push(`FAC-SEM-086: ${place} holds a value shaped like a credential; it names a kind, a role or an installer`);
653
+ }
654
+ return problems;
655
+ }
656
+
657
+ /**
658
+ * The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
659
+ * `not-offered`, `peer-invalid`, `data-class-unknown` or `above-max-data-class`; null when it may. The contract's `peerRefusal`.
660
+ */
661
+ export function peerRefusal(descriptor, dataClass) {
662
+ if (!isObj(descriptor) || !isObj(descriptor.peer)) return 'not-offered';
663
+ const max = typeof descriptor.peer.maxDataClass === 'string' ? PEER_DATA_CLASSES.indexOf(descriptor.peer.maxDataClass) : -1;
664
+ if (max < 0) return 'peer-invalid';
665
+ const asked = typeof dataClass === 'string' ? PEER_DATA_CLASSES.indexOf(dataClass) : -1;
666
+ if (asked < 0) return 'data-class-unknown';
667
+ return asked > max ? 'above-max-data-class' : null;
668
+ }
669
+
670
+ /**
671
+ * What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` from a translocated copy (macOS
672
+ * App Translocation); otherwise `write` when there is none, when it is not an app's descriptor, or when its version
673
+ * (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's `appDescriptorAction`.
674
+ */
675
+ export function appDescriptorAction(existing, tool, appPath) {
676
+ if (appPath.includes('/AppTranslocation/')) return 'keep';
677
+ if (!isObj(existing) || !isObj(existing.installedBy)) return 'write';
678
+ const recorded = isObj(existing.lifecycle) ? existing.lifecycle.app : undefined;
679
+ return existing.installedBy.tool === tool && recorded === appPath ? 'keep' : 'write';
680
+ }
681
+
682
+ /** Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find. */
683
+ export function appDescriptorStale(descriptor, appPresent) {
684
+ if (!isObj(descriptor) || !isObj(descriptor.lifecycle) || descriptor.lifecycle.manager !== 'person') return false;
685
+ return !appPresent;
686
+ }
687
+
688
+ /** Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file). */
689
+ export function appPresent(descriptor) {
690
+ const app = isObj(descriptor?.lifecycle) ? descriptor.lifecycle.app : undefined;
691
+ try { return typeof app === 'string' && fs.existsSync(expand(app)); } catch { return false; }
692
+ }
693
+
694
+ /** Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`. */
695
+ export const FABRIC_SERVICE = Object.freeze(['fabric', 'default']);
696
+
697
+ /**
698
+ * `present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
699
+ * descriptor is registered and, for an app's descriptor, its app is still installed; a server sets `data-fabric="absent"`
700
+ * on the button's script otherwise. The button only follows a link, so the liveness file is not read here.
701
+ */
702
+ export function fabricButtonPresence(dir = servicesDir()) {
703
+ let found;
704
+ try { found = JSON.parse(fs.readFileSync(descriptorPath(...FABRIC_SERVICE, dir), 'utf8')); } catch { return 'absent'; }
705
+ if (!isObj(found) || validateDescriptor(found).length) return 'absent';
706
+ return appDescriptorStale(found, appPresent(found)) ? 'absent' : 'present';
707
+ }
708
+
456
709
  export function placementOf(d) {
457
710
  return d?.placement === 'remote' ? 'remote' : 'local';
458
711
  }
@@ -476,8 +729,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
476
729
  const problems = validateDescriptor(d);
477
730
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
478
731
  const me = `${d.id}.${d.instance}`;
479
- // DEC-0019: only a local placement claims a port on this computer.
480
- const port = placementOf(d) === 'remote' ? null : portOf(d.origin);
732
+ // DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
733
+ const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
481
734
  for (const [file, other] of readDescriptors(dir)) {
482
735
  const key = `${other.id}.${other.instance ?? 'default'}`;
483
736
  if (key === me) continue;
@@ -540,6 +793,8 @@ const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
540
793
  const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
541
794
  const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
542
795
  export const ROUTES = Object.freeze(['subscription', 'key']);
796
+ /** A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models. */
797
+ export const USAGE_ROUTES = Object.freeze([...ROUTES, 'platform-metered']);
543
798
 
544
799
  /** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
545
800
  export function configurationProblems(doc) {
@@ -645,7 +900,7 @@ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, c
645
900
  if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
646
901
  const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
647
902
  if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
648
- if (route !== undefined && !ROUTES.includes(route)) throw new ServiceError('route is subscription or key (DEC-0046).');
903
+ if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
649
904
  return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(route === undefined ? {} : { route }) };
650
905
  }
651
906
 
@@ -1529,13 +1784,20 @@ export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
1529
1784
  * a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
1530
1785
  * never from an argument vector.
1531
1786
  */
1532
- export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(), installedBy = 'fabric-service register-remote' }) {
1787
+ /**
1788
+ * DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
1789
+ * descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
1790
+ * (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
1791
+ * credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
1792
+ */
1793
+ export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
1794
+ installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
1533
1795
  if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
1534
1796
  const tokenFile = remoteTokenFile(id, instance, dir);
1535
1797
  const d = {
1536
1798
  protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
1537
1799
  auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
1538
- installedAt: nowIso(), installedBy,
1800
+ installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
1539
1801
  };
1540
1802
  const problems = validateDescriptor(d);
1541
1803
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
@@ -1564,6 +1826,11 @@ function plain(value, what) {
1564
1826
  return value;
1565
1827
  }
1566
1828
 
1829
+ /** The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it. */
1830
+ function noMarkerArgument(argv) {
1831
+ if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
1832
+ }
1833
+
1567
1834
  function noSecretInSupervisorFile(argv, environment) {
1568
1835
  // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
1569
1836
  for (const arg of argv) {
@@ -1592,7 +1859,8 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
1592
1859
  const env = { ...environment };
1593
1860
  for (const name of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new ServiceError(`Environment name ${JSON.stringify(name)} is not a variable name.`);
1594
1861
  noSecretInSupervisorFile(argv, env);
1595
- env[SUPERVISOR_ENV] ??= 'systemd';
1862
+ noMarkerArgument(programArguments.map(String));
1863
+ env[SUPERVISOR_ENV] = 'systemd'; // the unit's writer writes the marker, whatever the caller passed
1596
1864
  const cwd = plain(String(workingDirectory), 'The working directory');
1597
1865
  if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
1598
1866
  return [
@@ -1666,6 +1934,10 @@ export function taskXml(task, programArguments, { workingDirectory, description,
1666
1934
  plain(String(workingDirectory), 'The working directory');
1667
1935
  plain(description ?? task, 'The description');
1668
1936
  noSecretInSupervisorFile(argv, {});
1937
+ noMarkerArgument(argv);
1938
+ // A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
1939
+ const cut = argv.includes('--') ? argv.indexOf('--') : argv.length;
1940
+ argv.splice(cut, 0, SUPERVISOR_ARG, 'task-scheduler');
1669
1941
  const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
1670
1942
  if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
1671
1943
  plain(who, 'The user');