@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.
- package/CHANGELOG.md +98 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +64 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -62
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +10 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
|
222
|
-
* 75
|
|
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
|
-
|
|
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):
|
|
267
|
-
*
|
|
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",
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
"$
|
|
309
|
-
|
|
310
|
-
"$
|
|
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 = $
|
|
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
|
-
/**
|
|
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
|
|
330
|
-
|
|
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,
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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');
|