@passioncode-ai/fabric-agent-adapter 0.16.0 → 0.18.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.
@@ -3,11 +3,13 @@
3
3
  // Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
4
4
  // placement — an online agent or dashboard at an https origin — DEC-0019; the usage report DEC-0021).
5
5
 
6
+ import { spawnSync } from 'node:child_process';
6
7
  import crypto from 'node:crypto';
7
8
  import fs from 'node:fs';
8
9
  import net from 'node:net';
9
10
  import os from 'node:os';
10
11
  import path from 'node:path';
12
+ import { credentialName, secretArgument, secretValue } from './fabric-secrets.mjs';
11
13
 
12
14
  export const PROTOCOL = 'fabric-service/0.1';
13
15
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
@@ -49,6 +51,13 @@ const CODE = /^[A-Za-z0-9_-]{16,256}$/;
49
51
  const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
50
52
  const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
51
53
  const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
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']);
57
+ const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
58
+ const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
59
+ export const WINDOWS_TRUSTED = Object.freeze(['S-1-5-18', 'S-1-5-32-544']);
60
+ export const DEFAULT_EXIT_TIMEOUT = 15;
52
61
 
53
62
  export class ServiceError extends Error {}
54
63
  export class AlreadyRunning extends ServiceError {
@@ -61,29 +70,42 @@ export class AlreadyRunning extends ServiceError {
61
70
 
62
71
  export const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
63
72
 
64
- export function expand(p) {
65
- const out = p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : p;
66
- if (!path.isAbsolute(out)) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
73
+ /** Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be absolute,
74
+ * and a network share is never a local path (DEC-0032). */
75
+ export function expand(p, platform = process.platform) {
76
+ if (!LOCAL_PATH.test(String(p))) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
77
+ const out = /^~[\\/]/.test(p) ? path.join(os.homedir(), p.slice(2)) : p;
78
+ // On Windows an absolute path names its drive: `/x` is relative to the current drive (the Python kit refuses it too).
79
+ const absolute = platform === 'win32' ? /^[A-Za-z]:[\\/]/.test(out) : path.posix.isAbsolute(out);
80
+ if (!absolute) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
67
81
  return out;
68
82
  }
69
83
 
70
- export function servicesDir() {
84
+ // DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
85
+ const localAppData = () => process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
86
+
87
+ export function servicesDir(platform = process.platform) {
71
88
  if (process.env.FABRIC_SERVICES_DIR) return expand(process.env.FABRIC_SERVICES_DIR);
72
89
  const home = os.homedir();
73
- if (process.platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
90
+ if (platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
91
+ if (platform === 'win32') return path.join(localAppData(), 'passioncode-fabric', 'services');
74
92
  return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric/services');
75
93
  }
76
94
 
77
- export function serviceDirs(id) {
95
+ export function serviceDirs(id, platform = process.platform) {
78
96
  if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
79
97
  const home = os.homedir();
80
- if (process.platform === 'darwin') {
98
+ if (platform === 'darwin') {
81
99
  return {
82
100
  data: path.join(home, 'Library/Application Support', id),
83
101
  logs: path.join(home, 'Library/Logs', id),
84
102
  cache: path.join(home, 'Library/Caches', id),
85
103
  };
86
104
  }
105
+ if (platform === 'win32') {
106
+ const data = path.join(localAppData(), id);
107
+ return { data, logs: path.join(data, 'Logs'), cache: path.join(data, 'Cache') };
108
+ }
87
109
  return {
88
110
  data: path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), id),
89
111
  logs: path.join(process.env.XDG_STATE_HOME || path.join(home, '.local/state'), id, 'logs'),
@@ -93,7 +115,8 @@ export function serviceDirs(id) {
93
115
 
94
116
  export function ensurePrivateDir(dir) {
95
117
  fs.mkdirSync(dir, { recursive: true });
96
- fs.chmodSync(dir, 0o700);
118
+ if (process.platform === 'win32') windowsProtect(dir, { folder: true });
119
+ else fs.chmodSync(dir, 0o700);
97
120
  return dir;
98
121
  }
99
122
 
@@ -102,13 +125,16 @@ export function atomicWrite(file, data, mode = 0o600) {
102
125
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
103
126
  const fd = fs.openSync(tmp, 'wx', mode);
104
127
  try {
128
+ // On Windows the file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in —
129
+ // so it does not depend on what the folder hands down. A rename keeps it.
130
+ if (process.platform === 'win32') windowsProtect(tmp, { folder: false });
105
131
  fs.writeSync(fd, data);
106
132
  fs.fsyncSync(fd);
107
133
  } finally {
108
134
  fs.closeSync(fd);
109
135
  }
110
136
  try {
111
- fs.chmodSync(tmp, mode);
137
+ if (process.platform !== 'win32') fs.chmodSync(tmp, mode);
112
138
  fs.renameSync(tmp, file);
113
139
  } catch (error) {
114
140
  fs.rmSync(tmp, { force: true });
@@ -140,11 +166,15 @@ export class InstanceLock {
140
166
  fs.fsyncSync(this.fd);
141
167
  return this;
142
168
  }
143
- // Linux: an abstract unix socket is exclusive, has no file, and dies with the process.
144
- const name = `\0fabric-service-${crypto.createHash('sha256').update(path.resolve(this.path)).digest('hex').slice(0, 32)}`;
169
+ // Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
170
+ // with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
171
+ const name = lockName(this.path);
172
+ if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
145
173
  this.server = net.createServer();
146
174
  await new Promise((resolve, reject) => {
147
- this.server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new AlreadyRunning(readPid(this.path), this.path) : error));
175
+ // Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
176
+ const held = (code) => code === 'EADDRINUSE' || (process.platform === 'win32' && code === 'EBUSY');
177
+ this.server.once('error', (error) => reject(held(error.code) ? new AlreadyRunning(readPid(this.path), this.path) : error));
148
178
  this.server.listen(name, resolve);
149
179
  });
150
180
  this.server.unref();
@@ -160,6 +190,15 @@ export class InstanceLock {
160
190
  }
161
191
  }
162
192
 
193
+ /** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
194
+ export function lockName(lockPath, platform = process.platform) {
195
+ const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
196
+ if (platform === 'linux') return `\0fabric-service-${digest(path.posix.resolve(lockPath))}`;
197
+ // Windows paths compare without case: C:\Users and c:\users are one lock.
198
+ if (platform === 'win32') return `\\\\.\\pipe\\fabric-service-${digest(path.win32.resolve(lockPath).toLowerCase())}`;
199
+ return null;
200
+ }
201
+
163
202
  function readPid(file) {
164
203
  try {
165
204
  const text = fs.readFileSync(file, 'utf8').trim();
@@ -171,6 +210,9 @@ function readPid(file) {
171
210
 
172
211
  /** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
173
212
  export const supervisedByLaunchd = () => process.env[SUPERVISOR_ENV] === 'launchd';
213
+ export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler']);
214
+ /** True when a supervisor that restarts on exit started this process and said so (launchd, systemd). */
215
+ export const supervised = () => SUPERVISORS.includes(process.env[SUPERVISOR_ENV]);
174
216
 
175
217
  const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
176
218
 
@@ -184,7 +226,7 @@ const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 100
184
226
  export async function holdSingleInstance(dataDir, {
185
227
  waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
186
228
  } = {}) {
187
- const wait = waitSeconds ?? (supervisedByLaunchd() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
229
+ const wait = waitSeconds ?? (supervised() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
188
230
  const deadline = clock() + Math.max(0, wait);
189
231
  let delay = LOCK_BACKOFF_FIRST_SECONDS;
190
232
  let announced = false;
@@ -218,16 +260,110 @@ export function ensureToken(file) {
218
260
  return token;
219
261
  }
220
262
 
263
+ /**
264
+ * Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
265
+ * service.md "Windows token files" (DEC-0032, DEC-0033): a regular file whose real path is in the
266
+ * profile, owned by the current user, SYSTEM or Administrators, every granting ACE naming one of
267
+ * those three; a refusal names the SID, never the contents.
268
+ */
221
269
  export function readToken(file) {
222
270
  const info = fs.lstatSync(file);
223
271
  if (info.isSymbolicLink()) throw new ServiceError(`Token file ${file} is a symlink; refusing it.`);
224
- if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
225
- if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
272
+ if (process.platform === 'win32') {
273
+ const problem = windowsTokenProblem(file, info);
274
+ if (problem) throw new ServiceError(problem);
275
+ } else {
276
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
277
+ if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
278
+ }
226
279
  const token = fs.readFileSync(file, 'utf8').trim();
227
280
  if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
228
281
  return token;
229
282
  }
230
283
 
284
+ // --- Windows ACLs (DEC-0032) ------------------------------------------------------
285
+ // Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
286
+ // and icacls writes it; both get the path through the environment, never argv, and print no contents.
287
+
288
+ /**
289
+ * service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the current
290
+ * user, SYSTEM or Administrators (DEC-0033), and every SID an allow ACE grants anything to is one of
291
+ * those three. Deny ACEs are not passed in: they do not change the decision.
292
+ */
293
+ export function tokenAclProblem(file, owner, granting, user) {
294
+ if (owner !== user && !WINDOWS_TRUSTED.includes(owner)) {
295
+ return `Token file ${file} is owned by ${owner || 'nobody'}, not by this user (${user}), SYSTEM or Administrators.`;
296
+ }
297
+ for (const sid of granting) {
298
+ if (sid !== user && !WINDOWS_TRUSTED.includes(sid)) return `Token file ${file} grants access to ${sid}; only this user, SYSTEM and Administrators may hold any.`;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ const ACL_SCRIPT = [
304
+ "$ErrorActionPreference = 'Stop'",
305
+ '$sid = [System.Security.Principal.SecurityIdentifier]',
306
+ '$acl = [System.IO.File]::GetAccessControl($env:FABRIC_ACL_PATH)',
307
+ "$null_dacl = $acl.GetSecurityDescriptorSddlForm('Access') -match 'NO_ACCESS_CONTROL' -or $acl.GetSecurityDescriptorSddlForm('Access') -eq ''",
308
+ // InheritOnly (2) applies to children, not to the file itself.
309
+ "$granting = @($acl.GetAccessRules($true, $true, $sid) | Where-Object { $_.AccessControlType -eq 'Allow' -and -not ([int]$_.PropagationFlags -band 2) } | ForEach-Object { $_.IdentityReference.Value })",
310
+ '$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
311
+ '[Console]::Out.Write((ConvertTo-Json -Compress @{ owner = $acl.GetOwner($sid).Value; granting = $granting; nullDacl = [bool]$null_dacl; user = $user }))',
312
+ ].join('; ');
313
+
314
+ function windowsHelper(command, args, extraEnv = {}) {
315
+ // No PSModulePath: the script uses .NET only, and a module path inherited from PowerShell 7 makes
316
+ // Windows PowerShell load the wrong modules.
317
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toLowerCase() !== 'psmodulepath'));
318
+ const result = spawnSync(command, args, { env: { ...env, ...extraEnv }, encoding: 'utf8', windowsHide: true, timeout: 30_000 });
319
+ if (result.error || result.status !== 0) {
320
+ throw new ServiceError(`${command} failed: ${(result.error?.message || result.stderr || result.stdout || '').trim().slice(0, 200)}`);
321
+ }
322
+ return result.stdout;
323
+ }
324
+
325
+ /** The ACL report PowerShell prints, reduced to (owner, granting SIDs, current user). */
326
+ export function parseAclReport(text) {
327
+ const report = JSON.parse(text);
328
+ const granting = report.nullDacl ? ['S-1-1-0'] : [].concat(report.granting ?? []);
329
+ return { owner: report.owner ?? '', granting, user: report.user };
330
+ }
331
+
332
+ /** (owner SID, SIDs of every allow ACE on the file itself, the current user's SID). */
333
+ export function windowsAcl(file) {
334
+ return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
335
+ }
336
+
337
+ let userSid = null;
338
+ export function windowsUserSid() {
339
+ if (!userSid) {
340
+ const line = windowsHelper('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim();
341
+ userSid = (line.match(/"(S-1-[0-9-]+)"\s*$/) ?? [])[1];
342
+ if (!userSid) throw new ServiceError('whoami /user printed no SID.');
343
+ }
344
+ return userSid;
345
+ }
346
+
347
+ /**
348
+ * A protected list (inheritance off): this user, SYSTEM, Administrators, full control; a folder hands it down.
349
+ * `/reset` first drops any explicit entry already there (`/inheritance:r` keeps those), so the three are all that remain.
350
+ */
351
+ export function windowsProtect(target, { folder }) {
352
+ const grant = folder ? '(OI)(CI)F' : 'F';
353
+ windowsHelper('icacls.exe', [target, '/reset']);
354
+ windowsHelper('icacls.exe', [target, '/inheritance:r', '/grant:r', ...[windowsUserSid(), ...WINDOWS_TRUSTED].map((sid) => `*${sid}:${grant}`)]);
355
+ }
356
+
357
+ function windowsTokenProblem(file, info) {
358
+ if (!info.isFile()) return `Token file ${file} is not a regular file (a link or junction); refusing it.`;
359
+ const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
360
+ const real = fs.realpathSync.native(file).toLowerCase();
361
+ const inside = path.win32.relative(profile, real);
362
+ if (!inside || inside.startsWith('..') || path.win32.isAbsolute(inside)) return `Token file ${file} lies outside the user's profile; refusing it.`;
363
+ const { owner, granting, user } = windowsAcl(file);
364
+ return tokenAclProblem(file, owner, granting, user);
365
+ }
366
+
231
367
  export function tokenMatches(presented, token, scheme = 'Bearer') {
232
368
  if (!presented) return false;
233
369
  let value = presented;
@@ -278,22 +414,37 @@ export function validateDescriptor(d) {
278
414
  if (remote) problems.push(...remoteOriginProblems(d.origin));
279
415
  else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
280
416
  if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
417
+ else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
418
+ for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
419
+ if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
420
+ }
281
421
  if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
282
422
  problems.push('a custom auth header carries the raw token: scheme must be none');
283
423
  }
284
- if (!['launchd', 'none'].includes(d.lifecycle?.manager)) problems.push('lifecycle.manager must be launchd or none');
285
- if (d.lifecycle?.manager === 'launchd' && !(d.lifecycle.label && String(d.lifecycle.plist ?? '').endsWith('.plist'))) {
424
+ const life = d.lifecycle ?? {};
425
+ const manager = life.manager;
426
+ if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
427
+ if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
286
428
  problems.push('a launchd service declares label and plist');
287
429
  }
430
+ if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
431
+ // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
432
+ for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
433
+ if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
434
+ if (life[field] !== undefined && manager !== owner) problems.push(`lifecycle.${field} belongs to manager ${owner} only`);
435
+ if (life[field] !== undefined && !pattern.test(String(life[field]))) problems.push(`lifecycle.${field} must match ${pattern.source}`);
436
+ }
288
437
  if (remote) {
289
- if (d.lifecycle?.manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
290
- for (const field of ['label', 'plist']) if (d.lifecycle?.[field] !== undefined) problems.push(`a remote service has no launchd ${field}`);
438
+ if (manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
439
+ for (const [field, of] of [['label', 'launchd'], ['plist', 'launchd'], ['unit', 'systemd'], ['task', 'Task Scheduler']]) {
440
+ if (life[field] !== undefined) problems.push(`a remote service has no ${of} ${field}`);
441
+ }
291
442
  if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
292
443
  }
293
444
  for (const [name, argv] of Object.entries(d.commands ?? {})) {
294
445
  if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
295
446
  else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
296
- else if (!/^(~\/|\/)/.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
447
+ else if (!LOCAL_PATH.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
297
448
  }
298
449
  return problems;
299
450
  }
@@ -959,3 +1110,174 @@ export function cookieValue(header, name = SESSION_COOKIE) {
959
1110
  }
960
1111
  return null;
961
1112
  }
1113
+
1114
+ // --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") -------------------------
1115
+ // The twins of fabric_service.systemd_unit / task_xml and their install and uninstall. `run(argv)`
1116
+ // returns spawnSync's {status, stdout, stderr}; the tests pass a recorder instead of the real tools.
1117
+
1118
+ const defaultRun = (argv) => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', windowsHide: true });
1119
+
1120
+ function plain(value, what) {
1121
+ // A supervisor file is line- and XML-structured: no control character in anything written into it.
1122
+ if (/[\x00-\x1f\x7f]/.test(value)) throw new ServiceError(`${what} holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it.`);
1123
+ return value;
1124
+ }
1125
+
1126
+ function noSecretInSupervisorFile(argv, environment) {
1127
+ // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
1128
+ for (const arg of argv) {
1129
+ if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a supervisor file never does (read it from the token file or a secret reference).');
1130
+ }
1131
+ for (const [name, value] of Object.entries(environment ?? {})) {
1132
+ if (credentialName(name) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
1133
+ }
1134
+ }
1135
+
1136
+ function check(result, what) {
1137
+ if (result?.status !== 0) throw new ServiceError(`${what} failed: ${String(result?.stderr || result?.stdout || result?.error?.message || '').trim().slice(0, 200)}`);
1138
+ }
1139
+
1140
+ const systemdQuote = (value) => `"${plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
1141
+
1142
+ /**
1143
+ * A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
1144
+ * WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd).
1145
+ */
1146
+ export function systemdUnit(unit, programArguments, { workingDirectory, environment = {}, description, stopTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
1147
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1148
+ const argv = programArguments.map(String);
1149
+ if (!argv.length || !argv[0].startsWith('/')) throw new ServiceError('A systemd unit starts an absolute executable.');
1150
+ if (!(stopTimeout > DRAIN_SECONDS)) throw new ServiceError(`TimeoutStopSec must outlast the ${DRAIN_SECONDS} s drain.`);
1151
+ const env = { ...environment };
1152
+ 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.`);
1153
+ noSecretInSupervisorFile(argv, env);
1154
+ env[SUPERVISOR_ENV] ??= 'systemd';
1155
+ const cwd = plain(String(workingDirectory), 'The working directory');
1156
+ if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
1157
+ return [
1158
+ '[Unit]', `Description=${plain(description ?? unit, 'The description').replace(/%/g, '%%')}`, '',
1159
+ '[Service]', 'Type=simple', `ExecStart=${argv.map(systemdQuote).join(' ')}`,
1160
+ // Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
1161
+ `WorkingDirectory=${cwd.replace(/%/g, '%%')}`,
1162
+ ...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
1163
+ 'Restart=always', 'RestartSec=10', `TimeoutStopSec=${Math.trunc(stopTimeout)}`, 'KillMode=mixed', '',
1164
+ '[Install]', 'WantedBy=default.target', '',
1165
+ ].join('\n');
1166
+ }
1167
+
1168
+ const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
1169
+
1170
+ /**
1171
+ * Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
1172
+ * The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
1173
+ * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
1174
+ */
1175
+ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
1176
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
1177
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1178
+ fs.mkdirSync(unitDir, { recursive: true });
1179
+ const file = path.join(unitDir, unit);
1180
+ const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
1181
+ atomicWrite(file, text, 0o644);
1182
+ check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
1183
+ if (state === 'disabled' && !forceEnable) return file;
1184
+ check(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
1185
+ check(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
1186
+ return file;
1187
+ }
1188
+
1189
+ /** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
1190
+ export function systemdUninstall(unit, { unitDir = systemdDir(), run = defaultRun, platform = process.platform } = {}) {
1191
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
1192
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1193
+ run(['systemctl', '--user', 'disable', '--now', unit]);
1194
+ fs.rmSync(path.join(unitDir, unit), { force: true });
1195
+ check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
1196
+ }
1197
+
1198
+ /** One command line from arguments, quoted the way the Microsoft C runtime parses it (Python's list2cmdline). */
1199
+ export function windowsCommandLine(args) {
1200
+ return args.map((arg) => {
1201
+ const quote = arg === '' || /[ \t]/.test(arg);
1202
+ let out = quote ? '"' : '';
1203
+ let slashes = 0;
1204
+ for (const c of arg) {
1205
+ if (c === '\\') { slashes += 1; continue; }
1206
+ if (c === '"') { out += '\\'.repeat(slashes * 2 + 1) + '"'; slashes = 0; continue; }
1207
+ out += '\\'.repeat(slashes) + c;
1208
+ slashes = 0;
1209
+ }
1210
+ return out + '\\'.repeat(quote ? slashes * 2 : slashes) + (quote ? '"' : '');
1211
+ }).join(' ');
1212
+ }
1213
+
1214
+ const xml = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1215
+
1216
+ /**
1217
+ * A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution
1218
+ * time limit, run only when the user is logged on, created without administrator rights.
1219
+ */
1220
+ export function taskXml(task, programArguments, { workingDirectory, description, user } = {}) {
1221
+ if (!TASK.test(task)) throw new ServiceError(`task must match ${TASK.source}`);
1222
+ const argv = programArguments.map(String);
1223
+ if (!argv.length || !/^[A-Za-z]:[\\/]/.test(argv[0])) throw new ServiceError('A task starts a drive-absolute executable (C:\\...).');
1224
+ for (const arg of argv) plain(arg, 'An argument');
1225
+ plain(String(workingDirectory), 'The working directory');
1226
+ plain(description ?? task, 'The description');
1227
+ noSecretInSupervisorFile(argv, {});
1228
+ const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
1229
+ if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
1230
+ plain(who, 'The user');
1231
+ return `<?xml version="1.0" encoding="UTF-16"?>
1232
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1233
+ <RegistrationInfo><Description>${xml(description ?? task)}</Description><URI>${xml(task)}</URI></RegistrationInfo>
1234
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml(who)}</UserId></LogonTrigger></Triggers>
1235
+ <Principals><Principal id="Author"><UserId>${xml(who)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
1236
+ <Settings>
1237
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1238
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1239
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1240
+ <StartWhenAvailable>true</StartWhenAvailable>
1241
+ <Enabled>true</Enabled>
1242
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1243
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
1244
+ </Settings>
1245
+ <Actions Context="Author"><Exec><Command>${xml(argv[0])}</Command><Arguments>${xml(windowsCommandLine(argv.slice(1)))}</Arguments><WorkingDirectory>${xml(workingDirectory)}</WorkingDirectory></Exec></Actions>
1246
+ </Task>
1247
+ `;
1248
+ }
1249
+
1250
+ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Enabled>/;
1251
+
1252
+ /**
1253
+ * Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
1254
+ * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
1255
+ */
1256
+ export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
1257
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
1258
+ const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
1259
+ const existed = query?.status === 0;
1260
+ const keepOff = existed && !forceEnable && TASK_DISABLED.test(String(query?.stdout ?? ''));
1261
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-task-'));
1262
+ const file = path.join(dir, 'task.xml');
1263
+ try {
1264
+ // schtasks reads the encoding the declaration names: UTF-16 little-endian with its byte order mark.
1265
+ fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xmlText, 'utf16le')]));
1266
+ check(run(['schtasks', '/Create', '/TN', task, '/XML', file, '/F']), 'schtasks /Create');
1267
+ } finally {
1268
+ fs.rmSync(dir, { recursive: true, force: true });
1269
+ }
1270
+ if (keepOff) {
1271
+ check(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
1272
+ return;
1273
+ }
1274
+ if (existed) run(['schtasks', '/End', '/TN', task]);
1275
+ check(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
1276
+ }
1277
+
1278
+ /** End the running task, then delete it. */
1279
+ export function taskUninstall(task, { run = defaultRun, platform = process.platform } = {}) {
1280
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
1281
+ run(['schtasks', '/End', '/TN', task]);
1282
+ check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
1283
+ }