@passioncode-ai/fabric-agent-adapter 0.17.0 → 0.19.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,14 @@ 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 AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
58
+ const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
59
+ const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
60
+ export const WINDOWS_TRUSTED = Object.freeze(['S-1-5-18', 'S-1-5-32-544']);
61
+ export const DEFAULT_EXIT_TIMEOUT = 15;
52
62
 
53
63
  export class ServiceError extends Error {}
54
64
  export class AlreadyRunning extends ServiceError {
@@ -61,29 +71,42 @@ export class AlreadyRunning extends ServiceError {
61
71
 
62
72
  export const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
63
73
 
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 ~/.`);
74
+ /** Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be absolute,
75
+ * and a network share is never a local path (DEC-0032). */
76
+ export function expand(p, platform = process.platform) {
77
+ if (!LOCAL_PATH.test(String(p))) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
78
+ const out = /^~[\\/]/.test(p) ? path.join(os.homedir(), p.slice(2)) : p;
79
+ // On Windows an absolute path names its drive: `/x` is relative to the current drive (the Python kit refuses it too).
80
+ const absolute = platform === 'win32' ? /^[A-Za-z]:[\\/]/.test(out) : path.posix.isAbsolute(out);
81
+ if (!absolute) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
67
82
  return out;
68
83
  }
69
84
 
70
- export function servicesDir() {
85
+ // DEC-0032: LOCALAPPDATA, not APPDATA, so token files never roam with the profile.
86
+ const localAppData = () => process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
87
+
88
+ export function servicesDir(platform = process.platform) {
71
89
  if (process.env.FABRIC_SERVICES_DIR) return expand(process.env.FABRIC_SERVICES_DIR);
72
90
  const home = os.homedir();
73
- if (process.platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
91
+ if (platform === 'darwin') return path.join(home, 'Library/Application Support/ai.passioncode.fabric/services');
92
+ if (platform === 'win32') return path.join(localAppData(), 'passioncode-fabric', 'services');
74
93
  return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), 'passioncode-fabric/services');
75
94
  }
76
95
 
77
- export function serviceDirs(id) {
96
+ export function serviceDirs(id, platform = process.platform) {
78
97
  if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
79
98
  const home = os.homedir();
80
- if (process.platform === 'darwin') {
99
+ if (platform === 'darwin') {
81
100
  return {
82
101
  data: path.join(home, 'Library/Application Support', id),
83
102
  logs: path.join(home, 'Library/Logs', id),
84
103
  cache: path.join(home, 'Library/Caches', id),
85
104
  };
86
105
  }
106
+ if (platform === 'win32') {
107
+ const data = path.join(localAppData(), id);
108
+ return { data, logs: path.join(data, 'Logs'), cache: path.join(data, 'Cache') };
109
+ }
87
110
  return {
88
111
  data: path.join(process.env.XDG_DATA_HOME || path.join(home, '.local/share'), id),
89
112
  logs: path.join(process.env.XDG_STATE_HOME || path.join(home, '.local/state'), id, 'logs'),
@@ -93,7 +116,8 @@ export function serviceDirs(id) {
93
116
 
94
117
  export function ensurePrivateDir(dir) {
95
118
  fs.mkdirSync(dir, { recursive: true });
96
- fs.chmodSync(dir, 0o700);
119
+ if (process.platform === 'win32') windowsProtect(dir, { folder: true });
120
+ else fs.chmodSync(dir, 0o700);
97
121
  return dir;
98
122
  }
99
123
 
@@ -102,13 +126,16 @@ export function atomicWrite(file, data, mode = 0o600) {
102
126
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
103
127
  const fd = fs.openSync(tmp, 'wx', mode);
104
128
  try {
129
+ // On Windows the file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in —
130
+ // so it does not depend on what the folder hands down. A rename keeps it.
131
+ if (process.platform === 'win32') windowsProtect(tmp, { folder: false });
105
132
  fs.writeSync(fd, data);
106
133
  fs.fsyncSync(fd);
107
134
  } finally {
108
135
  fs.closeSync(fd);
109
136
  }
110
137
  try {
111
- fs.chmodSync(tmp, mode);
138
+ if (process.platform !== 'win32') fs.chmodSync(tmp, mode);
112
139
  fs.renameSync(tmp, file);
113
140
  } catch (error) {
114
141
  fs.rmSync(tmp, { force: true });
@@ -140,11 +167,15 @@ export class InstanceLock {
140
167
  fs.fsyncSync(this.fd);
141
168
  return this;
142
169
  }
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)}`;
170
+ // Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
171
+ // with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
172
+ const name = lockName(this.path);
173
+ if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
145
174
  this.server = net.createServer();
146
175
  await new Promise((resolve, reject) => {
147
- this.server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new AlreadyRunning(readPid(this.path), this.path) : error));
176
+ // Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
177
+ const held = (code) => code === 'EADDRINUSE' || (process.platform === 'win32' && code === 'EBUSY');
178
+ this.server.once('error', (error) => reject(held(error.code) ? new AlreadyRunning(readPid(this.path), this.path) : error));
148
179
  this.server.listen(name, resolve);
149
180
  });
150
181
  this.server.unref();
@@ -160,6 +191,15 @@ export class InstanceLock {
160
191
  }
161
192
  }
162
193
 
194
+ /** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
195
+ export function lockName(lockPath, platform = process.platform) {
196
+ const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
197
+ if (platform === 'linux') return `\0fabric-service-${digest(path.posix.resolve(lockPath))}`;
198
+ // Windows paths compare without case: C:\Users and c:\users are one lock.
199
+ if (platform === 'win32') return `\\\\.\\pipe\\fabric-service-${digest(path.win32.resolve(lockPath).toLowerCase())}`;
200
+ return null;
201
+ }
202
+
163
203
  function readPid(file) {
164
204
  try {
165
205
  const text = fs.readFileSync(file, 'utf8').trim();
@@ -171,6 +211,9 @@ function readPid(file) {
171
211
 
172
212
  /** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
173
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]);
174
217
 
175
218
  const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
176
219
 
@@ -184,7 +227,7 @@ const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 100
184
227
  export async function holdSingleInstance(dataDir, {
185
228
  waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
186
229
  } = {}) {
187
- const wait = waitSeconds ?? (supervisedByLaunchd() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
230
+ const wait = waitSeconds ?? (supervised() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
188
231
  const deadline = clock() + Math.max(0, wait);
189
232
  let delay = LOCK_BACKOFF_FIRST_SECONDS;
190
233
  let announced = false;
@@ -218,16 +261,110 @@ export function ensureToken(file) {
218
261
  return token;
219
262
  }
220
263
 
264
+ /**
265
+ * 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.
269
+ */
221
270
  export function readToken(file) {
222
271
  const info = fs.lstatSync(file);
223
272
  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.`);
273
+ if (process.platform === 'win32') {
274
+ const problem = windowsTokenProblem(file, info);
275
+ if (problem) throw new ServiceError(problem);
276
+ } else {
277
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
278
+ if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
279
+ }
226
280
  const token = fs.readFileSync(file, 'utf8').trim();
227
281
  if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
228
282
  return token;
229
283
  }
230
284
 
285
+ // --- Windows ACLs (DEC-0032) ------------------------------------------------------
286
+ // Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
287
+ // and icacls writes it; both get the path through the environment, never argv, and print no contents.
288
+
289
+ /**
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.
293
+ */
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.`;
300
+ }
301
+ return null;
302
+ }
303
+
304
+ const ACL_SCRIPT = [
305
+ "$ErrorActionPreference = 'Stop'",
306
+ '$sid = [System.Security.Principal.SecurityIdentifier]',
307
+ '$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 })",
311
+ '$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 }))',
313
+ ].join('; ');
314
+
315
+ function windowsHelper(command, args, extraEnv = {}) {
316
+ // No PSModulePath: the script uses .NET only, and a module path inherited from PowerShell 7 makes
317
+ // Windows PowerShell load the wrong modules.
318
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toLowerCase() !== 'psmodulepath'));
319
+ const result = spawnSync(command, args, { env: { ...env, ...extraEnv }, encoding: 'utf8', windowsHide: true, timeout: 30_000 });
320
+ if (result.error || result.status !== 0) {
321
+ throw new ServiceError(`${command} failed: ${(result.error?.message || result.stderr || result.stdout || '').trim().slice(0, 200)}`);
322
+ }
323
+ return result.stdout;
324
+ }
325
+
326
+ /** The ACL report PowerShell prints, reduced to (owner, granting SIDs, current user). */
327
+ export function parseAclReport(text) {
328
+ 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 };
331
+ }
332
+
333
+ /** (owner SID, SIDs of every allow ACE on the file itself, the current user's SID). */
334
+ export function windowsAcl(file) {
335
+ return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
336
+ }
337
+
338
+ let userSid = null;
339
+ export function windowsUserSid() {
340
+ if (!userSid) {
341
+ const line = windowsHelper('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim();
342
+ userSid = (line.match(/"(S-1-[0-9-]+)"\s*$/) ?? [])[1];
343
+ if (!userSid) throw new ServiceError('whoami /user printed no SID.');
344
+ }
345
+ return userSid;
346
+ }
347
+
348
+ /**
349
+ * A protected list (inheritance off): this user, SYSTEM, Administrators, full control; a folder hands it down.
350
+ * `/reset` first drops any explicit entry already there (`/inheritance:r` keeps those), so the three are all that remain.
351
+ */
352
+ export function windowsProtect(target, { folder }) {
353
+ const grant = folder ? '(OI)(CI)F' : 'F';
354
+ windowsHelper('icacls.exe', [target, '/reset']);
355
+ windowsHelper('icacls.exe', [target, '/inheritance:r', '/grant:r', ...[windowsUserSid(), ...WINDOWS_TRUSTED].map((sid) => `*${sid}:${grant}`)]);
356
+ }
357
+
358
+ function windowsTokenProblem(file, info) {
359
+ if (!info.isFile()) return `Token file ${file} is not a regular file (a link or junction); refusing it.`;
360
+ const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
361
+ const real = fs.realpathSync.native(file).toLowerCase();
362
+ 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);
366
+ }
367
+
231
368
  export function tokenMatches(presented, token, scheme = 'Bearer') {
232
369
  if (!presented) return false;
233
370
  let value = presented;
@@ -278,22 +415,39 @@ export function validateDescriptor(d) {
278
415
  if (remote) problems.push(...remoteOriginProblems(d.origin));
279
416
  else if (!ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
280
417
  if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
418
+ else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
419
+ for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
420
+ if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
421
+ }
422
+ if (!(typeof (d.auth?.header ?? 'Authorization') === 'string' && AUTH_HEADER.test(d.auth?.header ?? 'Authorization'))) problems.push(`auth.header must match ${AUTH_HEADER.source}`);
423
+ if (!['Bearer', 'none'].includes(d.auth?.scheme ?? 'Bearer')) problems.push('auth.scheme is Bearer or none');
281
424
  if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
282
425
  problems.push('a custom auth header carries the raw token: scheme must be none');
283
426
  }
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'))) {
427
+ const life = d.lifecycle ?? {};
428
+ const manager = life.manager;
429
+ if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler or none');
430
+ if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
286
431
  problems.push('a launchd service declares label and plist');
287
432
  }
433
+ if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
434
+ // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
435
+ for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
436
+ if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
437
+ if (life[field] !== undefined && manager !== owner) problems.push(`lifecycle.${field} belongs to manager ${owner} only`);
438
+ if (life[field] !== undefined && !pattern.test(String(life[field]))) problems.push(`lifecycle.${field} must match ${pattern.source}`);
439
+ }
288
440
  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}`);
441
+ if (manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
442
+ for (const [field, of] of [['label', 'launchd'], ['plist', 'launchd'], ['unit', 'systemd'], ['task', 'Task Scheduler']]) {
443
+ if (life[field] !== undefined) problems.push(`a remote service has no ${of} ${field}`);
444
+ }
291
445
  if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
292
446
  }
293
447
  for (const [name, argv] of Object.entries(d.commands ?? {})) {
294
448
  if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
295
449
  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`);
450
+ else if (!LOCAL_PATH.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
297
451
  }
298
452
  return problems;
299
453
  }
@@ -507,7 +661,14 @@ function closeRow(row) {
507
661
  * model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
508
662
  * are the sums of the model rows (FAC-SEM-025).
509
663
  */
510
- export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget } = {}) {
664
+ /**
665
+ * `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
666
+ * calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
667
+ * disagree; a per-order limit (`per_job`, `approval`) gets `spentUsd: null`; every other limit states its own spend.
668
+ * A report whose limits break FAC-SEM-031 is refused (`usageLimitProblems`).
669
+ */
670
+ export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget, budgets } = {}) {
671
+ receipts = Array.from(receipts); // read twice: the days, then a rolling window
511
672
  const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000), last = utcDate(now);
512
673
  const days = new Map();
513
674
  for (const r of receipts) {
@@ -538,14 +699,439 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
538
699
  const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
539
700
  if (budget !== undefined) {
540
701
  if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
541
- const prefix = budget.period === 'day' ? last : last.slice(0, 7);
542
- const window = outDays.filter((d) => d.date.startsWith(prefix));
543
- const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
544
- report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6 };
702
+ report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
703
+ }
704
+ if (budgets !== undefined) {
705
+ report.budgets = budgets.map((given) => {
706
+ if (!isPlainObject(given)) throw new ServiceError('Each limit in budgets is an object.');
707
+ const limit = { ...given };
708
+ if (!('spentUsd' in limit)) {
709
+ if (PER_ORDER_KINDS.includes(limit.kind)) limit.spentUsd = null;
710
+ else if (limit.scope === 'machine' && ['day', 'month'].includes(limit.period)) limit.spentUsd = spentIn(outDays, limit.period, last);
711
+ else if (limit.scope === 'machine' && isNumber(limit.windowSeconds)) limit.spentUsd = spentSince(receipts, now - limit.windowSeconds * 1000, now);
712
+ }
713
+ return limit;
714
+ });
715
+ const problems = usageLimitProblems(report);
716
+ if (problems.length) throw new ServiceError(`The report's limits are not coherent: ${problems.join('; ')}`);
545
717
  }
546
718
  return report;
547
719
  }
548
720
 
721
+ /** Spend in a rolling window, from the receipts' own times; null when every call in it is unpriced. */
722
+ function spentSince(receipts, start, end) {
723
+ const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
724
+ const lo = iso(start), hi = iso(end);
725
+ const inside = [...receipts].filter((r) => String(r.at ?? '') > lo && String(r.at ?? '') <= hi);
726
+ if (inside.length && inside.every((r) => r.costUsd === null || r.costUsd === undefined)) return null;
727
+ return Math.round(inside.reduce((n, r) => n + (r.costUsd ?? 0), 0) * 1e6) / 1e6;
728
+ }
729
+
730
+ /** Spend in the current UTC day or month; null when every call in it is unpriced. */
731
+ function spentIn(outDays, period, last) {
732
+ const prefix = period === 'day' ? last : last.slice(0, 7);
733
+ const window = outDays.filter((d) => d.date.startsWith(prefix));
734
+ const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
735
+ return unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6;
736
+ }
737
+
738
+ // --- every spending limit (DEC-0027, FAC-SEM-031) --------------------------------------------
739
+
740
+ export const LIMIT_SCOPES = Object.freeze(['machine', 'project', 'pool', 'job']);
741
+ export const PER_ORDER_KINDS = Object.freeze(['per_job', 'approval']);
742
+ const LIMIT_ID = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
743
+ const LIMIT_KIND = /^[a-z][a-z0-9_]{0,31}$/;
744
+ const LIMIT_FIELDS = ['id', 'scope', 'subject', 'kind', 'period', 'windowSeconds', 'since', 'limitUsd', 'spentUsd', 'enforced', 'tripped'];
745
+ const TIMESTAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
746
+
747
+ /** A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset. */
748
+ export function isTimestamp(value) {
749
+ const m = typeof value === 'string' ? value.match(TIMESTAMP) : null;
750
+ if (!m) return false;
751
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
752
+ const date = new Date(Date.UTC(year, month - 1, day));
753
+ const calendar = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
754
+ const offset = m[9] === undefined || (Number(m[9]) <= 23 && Number(m[10]) <= 59);
755
+ return calendar && hour <= 23 && minute <= 59 && second <= 60 && offset;
756
+ }
757
+ const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
758
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
759
+
760
+ /**
761
+ * The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031 — the twin of
762
+ * fabric_service.usage_limit_problems. A breach is not a problem, and an unknown kind is never refused.
763
+ */
764
+ export function usageLimitProblems(report) {
765
+ const problems = [];
766
+ const limits = report?.budgets;
767
+ if (limits === undefined) return problems;
768
+ if (!Array.isArray(limits) || limits.length > 64) return ['budgets is a list of at most 64 limits'];
769
+ const seen = new Set();
770
+ limits.forEach((limit, i) => {
771
+ const at = `/budgets/${i}`;
772
+ if (!isPlainObject(limit)) { problems.push(`${at} is not an object`); return; }
773
+ for (const key of Object.keys(limit)) if (!LIMIT_FIELDS.includes(key)) problems.push(`${at}/${key} is not a limit field`);
774
+ for (const key of ['id', 'scope', 'kind', 'limitUsd', 'spentUsd', 'enforced']) if (!(key in limit)) problems.push(`${at} is missing ${key}`);
775
+ if ('id' in limit && !(typeof limit.id === 'string' && LIMIT_ID.test(limit.id))) problems.push(`${at}/id must match ${LIMIT_ID.source}`);
776
+ if ('scope' in limit && !LIMIT_SCOPES.includes(limit.scope)) problems.push(`${at}/scope must be one of ${LIMIT_SCOPES.join(', ')}`);
777
+ if ('subject' in limit && !(typeof limit.subject === 'string' && limit.subject.length >= 1 && limit.subject.length <= 128)) problems.push(`${at}/subject is 1 to 128 characters`);
778
+ if ('kind' in limit && !(typeof limit.kind === 'string' && LIMIT_KIND.test(limit.kind))) problems.push(`${at}/kind must match ${LIMIT_KIND.source}`);
779
+ if ('period' in limit && !['day', 'month'].includes(limit.period)) problems.push(`${at}/period is day or month`);
780
+ if ('windowSeconds' in limit && !(Number.isInteger(limit.windowSeconds) && limit.windowSeconds >= 1)) problems.push(`${at}/windowSeconds is a whole number of seconds, at least 1`);
781
+ if ('since' in limit && !isTimestamp(limit.since)) problems.push(`${at}/since is a UTC timestamp`);
782
+ if ('limitUsd' in limit && !(limit.limitUsd === null || (isNumber(limit.limitUsd) && limit.limitUsd > 0))) problems.push(`${at}/limitUsd is a dollar amount above zero, or null`);
783
+ if ('spentUsd' in limit && !(limit.spentUsd === null || (isNumber(limit.spentUsd) && limit.spentUsd >= 0))) problems.push(`${at}/spentUsd is a dollar amount, zero or more, or null`);
784
+ for (const key of ['enforced', 'tripped']) if (key in limit && typeof limit[key] !== 'boolean') problems.push(`${at}/${key} is true or false`);
785
+
786
+ const flag = (where, message) => problems.push(`FAC-SEM-031 ${at}/${where}: ${message}`);
787
+ const id = String(limit.id ?? '');
788
+ if (seen.has(id)) flag('id', `limit id ${id} appears twice`);
789
+ seen.add(id);
790
+ const named = limit.scope === 'project' || limit.scope === 'pool';
791
+ if (named && !('subject' in limit)) flag('subject', `a ${limit.scope} limit names its ${limit.scope} in subject`);
792
+ if (!named && 'subject' in limit) flag('subject', `a ${limit.scope} limit has no subject`);
793
+ const windows = ['period', 'windowSeconds', 'since'].filter((f) => f in limit);
794
+ if (windows.length > 1) flag(windows[1], `a limit has one window, not ${windows.join(' and ')}`);
795
+ const kind = String(limit.kind ?? '');
796
+ const seconds = isNumber(limit.windowSeconds) ? limit.windowSeconds : null;
797
+ if (PER_ORDER_KINDS.includes(kind)) {
798
+ if (windows.length) flag(windows[0], `${kind} bounds one order and has no window`);
799
+ if (limit.spentUsd !== null) flag('spentUsd', `${kind} bounds one order, so nothing is spent against it (null)`);
800
+ } else if (kind === 'daily') {
801
+ if (!(limit.period === 'day' || seconds === 86400)) flag('period', 'a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)');
802
+ } else if (kind === 'monthly') {
803
+ if (!(limit.period === 'month' || (seconds !== null && seconds >= 28 * 86400 && seconds <= 31 * 86400))) flag('period', 'a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)');
804
+ } else if (kind === 'velocity') {
805
+ if (seconds === null) flag('windowSeconds', 'a velocity limit counts a rolling window (windowSeconds)');
806
+ } else if (kind === 'pool' || kind === 'emergency') {
807
+ if (!windows.length) flag('period', `a ${kind} limit names the window it counts (period, windowSeconds or since)`);
808
+ }
809
+ const known = PER_ORDER_KINDS.includes(kind) || ['daily', 'monthly', 'pool', 'emergency'].includes(kind);
810
+ if (known && 'limitUsd' in limit && limit.limitUsd === null) flag('limitUsd', `a ${kind} limit is a dollar amount; only a relative limit (velocity) may leave it null`);
811
+ if (limit.tripped === true && limit.enforced !== true) flag('tripped', 'a limit that is not enforced cannot have stopped work');
812
+ });
813
+ const budget = report.budget;
814
+ if (isPlainObject(budget) && limits.length) {
815
+ const kind = budget.period === 'day' ? 'daily' : 'monthly';
816
+ const same = (x, y) => (x === null || y === null ? x === y : isNumber(x) && isNumber(y) && Math.abs(x - y) <= 0.000001);
817
+ const twin = limits.some((l) => isPlainObject(l) && l.scope === 'machine' && l.enforced === true && l.kind === kind && l.period === budget.period && same(l.limitUsd, budget.limitUsd) && same(l.spentUsd, budget.spentUsd));
818
+ if (!twin) problems.push(`FAC-SEM-031 /budget: budget repeats one enforced machine ${kind} limit with period ${budget.period} in budgets[], so old and new readers agree`);
819
+ }
820
+ return problems;
821
+ }
822
+
823
+ // --- the feed client (DEC-0025, FAC-SEM-036) -------------------------------------------------
824
+
825
+ /**
826
+ * The one header a client sends the service token in, as the descriptor's `auth` declares it: `header` defaults
827
+ * to Authorization and `scheme` to Bearer; `none` carries the raw token. Never assume `Authorization: Bearer`.
828
+ */
829
+ export function tokenHeader(auth, token) {
830
+ const declared = isPlainObject(auth) ? auth : {};
831
+ const name = typeof declared.header === 'string' ? declared.header : 'Authorization';
832
+ const scheme = typeof declared.scheme === 'string' ? declared.scheme : 'Bearer';
833
+ return { name, value: scheme === 'none' ? token : `Bearer ${token}` };
834
+ }
835
+
836
+ // --- settings backups (DEC-0025, FAC-SEM-035) ------------------------------------------------
837
+ // The twins of fabric_service.make_settings_backup and the rest; both kits write byte-identical files.
838
+
839
+ export const SETTINGS_BACKUP_FORMAT = 'fabric-settings-backup/1';
840
+ export const SETTINGS_BACKUP_KEEP = 14;
841
+ export const SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60;
842
+ const TABLE = /^[a-z][a-z0-9_]{0,62}$/;
843
+ const SECRET_TABLE = /^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$/;
844
+ const COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
845
+ const SECRET_COLUMN = /^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$/;
846
+ const REASON = /^[a-z][a-z0-9_.-]{0,63}$/;
847
+ const SHA256 = /^[0-9a-f]{64}$/;
848
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
849
+ const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/;
850
+ const MAX_CELL_CHARS = 1048576;
851
+ const MAX_ROW_VALUES = 512;
852
+ const STALE_TEMP_MS = 60 * 60 * 1000;
853
+ const BACKUP_NAME = /^settings-[0-9]{8}T[0-9]{12}Z\.json$/;
854
+
855
+ function byCodePoint(a, b) {
856
+ const x = Array.from(a), y = Array.from(b);
857
+ for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
858
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
859
+ if (d !== 0) return d;
860
+ }
861
+ return x.length - y.length;
862
+ }
863
+
864
+ /**
865
+ * The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, `", "` and `": "`,
866
+ * strings escaped as JSON requires and nothing else, integers in decimal — Python's
867
+ * `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
868
+ */
869
+ export function settingsCanonicalJson(value) {
870
+ if (value === null) return 'null';
871
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
872
+ if (typeof value === 'number') {
873
+ if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
874
+ return String(value);
875
+ }
876
+ if (typeof value === 'string') return JSON.stringify(value);
877
+ if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
878
+ if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
879
+ return `{${Object.keys(value).sort(byCodePoint).map((key) => `${JSON.stringify(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
880
+ }
881
+ throw new ServiceError(`${typeof value} is not a JSON value`);
882
+ }
883
+
884
+ /** The `sha256` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of `tables`. */
885
+ export const settingsBackupDigest = (tables) => crypto.createHash('sha256').update(settingsCanonicalJson(tables), 'utf8').digest('hex');
886
+
887
+ /** Why a reader refuses this settings backup: its shape and FAC-SEM-035. Empty means intact. */
888
+ export function settingsBackupProblems(doc) {
889
+ if (!isPlainObject(doc)) return ['a settings backup is a JSON object'];
890
+ const problems = [];
891
+ if (doc.format !== SETTINGS_BACKUP_FORMAT) problems.push(`format must be ${SETTINGS_BACKUP_FORMAT}, not ${JSON.stringify(doc.format ?? null)}: a reader refuses a format it does not know`);
892
+ for (const key of ['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables']) if (!(key in doc)) problems.push(`missing ${key}`);
893
+ for (const key of Object.keys(doc)) if (!['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables', 'extensions'].includes(key)) problems.push(`${key} is not a settings backup field`);
894
+ if ('extensions' in doc) {
895
+ if (!isPlainObject(doc.extensions)) problems.push('extensions is an object');
896
+ else for (const key of Object.keys(doc.extensions)) if (!URI.test(key)) problems.push(`extensions key ${JSON.stringify(key)} is not a URI`);
897
+ }
898
+ if ('service' in doc) {
899
+ const s = doc.service;
900
+ if (!isPlainObject(s) || Object.keys(s).sort().join() !== 'id,instance,schema,version') problems.push('service is {id, instance, version, schema}');
901
+ else {
902
+ if (!ID.test(String(s.id))) problems.push(`service.id must match ${ID.source}`);
903
+ if (!INSTANCE.test(String(s.instance))) problems.push(`service.instance must match ${INSTANCE.source}`);
904
+ if (!(typeof s.version === 'string' && s.version.length <= 64)) problems.push('service.version is a string of at most 64 characters');
905
+ if (!((Number.isInteger(s.schema) && s.schema >= 0) || (typeof s.schema === 'string' && s.schema.length >= 1 && s.schema.length <= 64))) problems.push('service.schema is a whole number or a short string');
906
+ }
907
+ }
908
+ if ('created_at' in doc && !isTimestamp(doc.created_at)) problems.push('created_at is a UTC timestamp');
909
+ if ('reason' in doc && !(typeof doc.reason === 'string' && REASON.test(doc.reason))) problems.push(`reason must match ${REASON.source}`);
910
+ if ('sha256' in doc && !(typeof doc.sha256 === 'string' && SHA256.test(doc.sha256))) problems.push('sha256 is 64 lowercase hex digits');
911
+ const tables = isPlainObject(doc.tables) ? doc.tables : {};
912
+ const counts = isPlainObject(doc.counts) ? doc.counts : {};
913
+ if ('tables' in doc && !isPlainObject(doc.tables)) problems.push('tables is an object');
914
+ if ('counts' in doc && !isPlainObject(doc.counts)) problems.push('counts is an object');
915
+ let canonical = true;
916
+ for (const name of [...Object.keys(tables), ...Object.keys(counts).filter((n) => !Object.hasOwn(tables, n))]) {
917
+ if (LONE_SURROGATE.test(name)) canonical = false;
918
+ if (!TABLE.test(name) || SECRET_TABLE.test(name)) problems.push(`table ${name} is never backed up: it can only hold connections, tokens, secrets, credentials, passwords, login codes or sessions (or its name is not a table name)`);
919
+ }
920
+ for (const [name, count] of Object.entries(counts)) if (!(Number.isInteger(count) && count >= 0)) problems.push(`counts.${name} is a whole number`);
921
+ for (const [name, table] of Object.entries(tables)) {
922
+ if (!isPlainObject(table) || Object.keys(table).sort().join() !== 'columns,rows') { problems.push(`tables.${name} is {columns, rows}`); continue; }
923
+ let columns = table.columns;
924
+ if (!(Array.isArray(columns) && columns.length >= 1 && columns.length <= 512 && new Set(columns.map(String)).size === columns.length)) {
925
+ problems.push(`tables.${name}.columns is 1 to 512 distinct names`);
926
+ if (!Array.isArray(columns)) columns = [];
927
+ }
928
+ for (const column of columns) {
929
+ if (!(typeof column === 'string' && COLUMN.test(column)) || SECRET_COLUMN.test(String(column))) problems.push(`column ${name}.${column} is refused: its name says it holds a credential value (name the secret, never store it: secret_name, token_slot)`);
930
+ }
931
+ if (!Array.isArray(table.rows)) { problems.push(`tables.${name}.rows is a list`); continue; }
932
+ table.rows.forEach((row, i) => {
933
+ const at = `/tables/${name}/rows/${i}`;
934
+ if (!Array.isArray(row)) { problems.push(`${at} is a list of values`); return; }
935
+ if (row.length !== columns.length) problems.push(`FAC-SEM-035 ${at}: row has ${row.length} values for ${columns.length} columns`);
936
+ if (row.length > MAX_ROW_VALUES) problems.push(`${at} holds more than ${MAX_ROW_VALUES} values`);
937
+ row.forEach((cell, j) => {
938
+ if (typeof cell === 'number' && !Number.isSafeInteger(cell)) {
939
+ canonical = false;
940
+ problems.push(`FAC-SEM-035 ${at}/${j}: a fraction or an unsafe integer has no canonical form; store it as a string`);
941
+ } else if (typeof cell === 'string' && cell.length > MAX_CELL_CHARS) {
942
+ problems.push(`${at}/${j} is longer than ${MAX_CELL_CHARS} characters`);
943
+ } else if (typeof cell === 'string' && LONE_SURROGATE.test(cell)) {
944
+ canonical = false;
945
+ problems.push(`FAC-SEM-035 ${at}/${j}: a string with a lone surrogate cannot be encoded as UTF-8`);
946
+ } else if (!(cell === null || ['boolean', 'number', 'string'].includes(typeof cell))) {
947
+ canonical = false;
948
+ problems.push(`${at}/${j} is not a JSON scalar`);
949
+ }
950
+ });
951
+ });
952
+ if (counts[name] !== table.rows.length) problems.push(`FAC-SEM-035 /counts/${name}: counts says ${counts[name] ?? 'nothing'} for ${table.rows.length} rows`);
953
+ }
954
+ for (const name of Object.keys(counts)) if (!Object.hasOwn(tables, name)) problems.push(`FAC-SEM-035 /counts/${name}: counts names ${name}, which the backup does not carry`);
955
+ if (canonical && isPlainObject(doc.tables)) {
956
+ let digest = null;
957
+ try {
958
+ digest = settingsBackupDigest(tables);
959
+ } catch (error) {
960
+ problems.push(`FAC-SEM-035 /tables: the tables have no canonical form (${error.message})`);
961
+ }
962
+ if (digest !== null && doc.sha256 !== digest) problems.push('FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it');
963
+ }
964
+ return problems;
965
+ }
966
+
967
+ /** Where every service keeps its settings backups — outside its data directory (DEC-0025). */
968
+ export function settingsBackupRoot(platform = process.platform) {
969
+ if (platform === 'darwin') return path.join(os.homedir(), 'Library/Application Support/PassionCode');
970
+ if (platform === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'PassionCode');
971
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'PassionCode');
972
+ }
973
+
974
+ export function settingsBackupDir(id, instance = 'default', root = settingsBackupRoot()) {
975
+ if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
976
+ if (!INSTANCE.test(instance)) throw new ServiceError(`instance must match ${INSTANCE}`);
977
+ return path.join(root, 'settings-backups', id, instance);
978
+ }
979
+
980
+ const microStamp = (ms) => `${new Date(ms).toISOString().slice(0, 23)}000Z`;
981
+
982
+ /** A snapshot of the operator's decisions and bindings (the tables on the service's allow-list). Refuses a file a reader would refuse. */
983
+ export function makeSettingsBackup({ id, instance = 'default', version, schema, tables, reason, now = Date.now() }) {
984
+ const doc = {
985
+ format: SETTINGS_BACKUP_FORMAT, service: { id, instance, version, schema }, created_at: microStamp(now), reason, sha256: '',
986
+ counts: Object.fromEntries(Object.entries(tables).map(([name, table]) => [name, (table?.rows ?? []).length])), tables,
987
+ };
988
+ try {
989
+ doc.sha256 = settingsBackupDigest(tables);
990
+ } catch (error) {
991
+ throw new ServiceError(`This snapshot has no canonical form: ${error.message}`);
992
+ }
993
+ const problems = settingsBackupProblems(doc);
994
+ if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
995
+ return doc;
996
+ }
997
+
998
+ const backupName = (doc) => `settings-${doc.created_at.replace(/[-:]/g, '').replace(/\.(\d+)Z$/, (_, f) => `${(f + '000000').slice(0, 6)}Z`)}.json`;
999
+
1000
+ /** The snapshot files, newest first. */
1001
+ export function settingsBackups(dir) {
1002
+ if (!fs.existsSync(dir)) return [];
1003
+ return fs.readdirSync(dir).filter((n) => BACKUP_NAME.test(n)).sort().reverse().map((n) => path.join(dir, n));
1004
+ }
1005
+
1006
+ /** The first number written with a fraction or an exponent outside a string (`1.0`, `1e2`); JSON.parse would read
1007
+ * `1.0` as the integer 1, where the Python reader — and the canonical form — sees a fraction. */
1008
+ function fractionLexeme(text) {
1009
+ let inString = false;
1010
+ for (let i = 0; i < text.length; i += 1) {
1011
+ const c = text[i];
1012
+ if (inString) {
1013
+ if (c === '\\') i += 1;
1014
+ else if (c === '"') inString = false;
1015
+ } else if (c === '"') {
1016
+ inString = true;
1017
+ } else if (c === '-' || (c >= '0' && c <= '9')) {
1018
+ const lexeme = text.slice(i).match(/^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)[0];
1019
+ if (/[.eE]/.test(lexeme)) return lexeme;
1020
+ i += lexeme.length - 1;
1021
+ }
1022
+ }
1023
+ return null;
1024
+ }
1025
+
1026
+ /** A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035). */
1027
+ export function readSettingsBackup(file) {
1028
+ let doc;
1029
+ try {
1030
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(file));
1031
+ const fraction = fractionLexeme(text);
1032
+ if (fraction) throw new Error(`${fraction} is not an integer: a fraction is stored as its decimal string`);
1033
+ doc = JSON.parse(text);
1034
+ } catch (error) {
1035
+ throw new ServiceError(`Settings backup ${file} cannot be read: ${error.message}`);
1036
+ }
1037
+ const problems = settingsBackupProblems(doc);
1038
+ if (problems.length) throw new ServiceError(`Settings backup ${file} is refused: ${problems.join('; ')}`);
1039
+ return doc;
1040
+ }
1041
+
1042
+ /**
1043
+ * Write a snapshot: atomically, 0600 in a 0700 directory, named by its time. A temporary file an interrupted write
1044
+ * left is removed first; a snapshot whose checksum equals the newest file's is not written again (null). After a
1045
+ * write, all but the `keep` newest are removed (at least 14).
1046
+ */
1047
+ export function writeSettingsBackup(doc, dir, { keep = SETTINGS_BACKUP_KEEP } = {}) {
1048
+ const problems = settingsBackupProblems(doc);
1049
+ if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
1050
+ ensurePrivateDir(dir);
1051
+ // Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
1052
+ for (const left of fs.readdirSync(dir)) {
1053
+ const at = path.join(dir, left);
1054
+ try {
1055
+ if (left.startsWith('.settings-') && fs.statSync(at).mtimeMs < Date.now() - STALE_TEMP_MS) fs.rmSync(at, { force: true });
1056
+ } catch { /* gone meanwhile */ }
1057
+ }
1058
+ const existing = settingsBackups(dir);
1059
+ if (existing.length) {
1060
+ try {
1061
+ if (readSettingsBackup(existing[0]).sha256 === doc.sha256) return null;
1062
+ } catch { /* a damaged newest file is no reason to skip a good snapshot */ }
1063
+ }
1064
+ const file = path.join(dir, backupName(doc));
1065
+ atomicWrite(file, `${JSON.stringify(doc, null, 2)}\n`, 0o600);
1066
+ // The newest by name, but never the one just written: a clock that stepped back must not delete it.
1067
+ for (const old of settingsBackups(dir).filter((f) => f !== file).slice(Math.max(keep, SETTINGS_BACKUP_KEEP) - 1)) fs.rmSync(old, { force: true });
1068
+ return file;
1069
+ }
1070
+
1071
+ /** True when there is no snapshot or the newest is older than 24 hours. */
1072
+ export function backupDue(dir, now = Date.now()) {
1073
+ const newest = settingsBackups(dir)[0];
1074
+ if (!newest) return true;
1075
+ const m = path.basename(newest).match(/^settings-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})\d{3}Z\.json$/);
1076
+ // A name no calendar has: take a fresh snapshot rather than trust it (Date.UTC would roll it over).
1077
+ if (!isTimestamp(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`)) return true;
1078
+ const at = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]);
1079
+ return (now - at) / 1000 > SETTINGS_BACKUP_MAX_AGE_SECONDS;
1080
+ }
1081
+
1082
+ /** The source of an automatic restore: the newest intact snapshot with content; damaged ones are returned with the reason. */
1083
+ export function newestIntactBackup(dir) {
1084
+ const skipped = [];
1085
+ for (const file of settingsBackups(dir)) {
1086
+ let doc;
1087
+ try {
1088
+ doc = readSettingsBackup(file);
1089
+ } catch (error) {
1090
+ skipped.push({ file, reason: error.message });
1091
+ continue;
1092
+ }
1093
+ if (Object.values(doc.counts).some((n) => n > 0)) return { file, doc, skipped };
1094
+ }
1095
+ return { file: null, doc: null, skipped };
1096
+ }
1097
+
1098
+ /**
1099
+ * What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
1100
+ * required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
1101
+ */
1102
+ export function restorePlan(doc, current) {
1103
+ const problems = settingsBackupProblems(doc);
1104
+ if (problems.length) throw new ServiceError(`A damaged snapshot is never restored: ${problems.join('; ')}`);
1105
+ const plan = { source: { created_at: doc.created_at, sha256: doc.sha256 }, tables: {}, unknownTables: [] };
1106
+ for (const [name, table] of Object.entries(doc.tables)) {
1107
+ const now = current[name];
1108
+ if (!now) { plan.unknownTables.push(name); continue; }
1109
+ const columns = now.columns ?? [], key = now.key ?? [], required = now.required ?? [];
1110
+ const existing = new Set((now.existing ?? []).map((k) => JSON.stringify([...k])));
1111
+ const shared = table.columns.filter((c) => columns.includes(c));
1112
+ const missing = required.filter((c) => !table.columns.includes(c));
1113
+ const entry = {
1114
+ insert: [], present: 0, droppedColumns: table.columns.filter((c) => !columns.includes(c)),
1115
+ defaultedColumns: columns.filter((c) => !table.columns.includes(c)), missingRequired: missing, refused: [],
1116
+ };
1117
+ const planned = new Set();
1118
+ for (const row of table.rows) {
1119
+ const values = Object.fromEntries(table.columns.map((c, i) => [c, row[i]]));
1120
+ const ident = key.map((c) => values[c] ?? null);
1121
+ if (key.some((c) => !table.columns.includes(c))) entry.refused.push({ key: ident, reason: 'the primary key is not in the snapshot' });
1122
+ else if (planned.has(JSON.stringify(ident))) entry.refused.push({ key: ident, reason: 'the snapshot holds this key twice' });
1123
+ else if (existing.has(JSON.stringify(ident))) entry.present += 1;
1124
+ else if (missing.length) entry.refused.push({ key: ident, reason: `required column ${missing.join(', ')} is not in the snapshot` });
1125
+ else {
1126
+ planned.add(JSON.stringify(ident));
1127
+ entry.insert.push(Object.fromEntries(shared.map((c) => [c, values[c]])));
1128
+ }
1129
+ }
1130
+ plan.tables[name] = entry;
1131
+ }
1132
+ return plan;
1133
+ }
1134
+
549
1135
  /** Usage receipts for services that keep none yet: append-only JSON lines, 0600, pruned to the window (LC-12).
550
1136
  * One writer process (the service holds its instance lock); every method is synchronous, so calls never interleave. */
551
1137
  export class JsonlUsageLedger {
@@ -959,3 +1545,174 @@ export function cookieValue(header, name = SESSION_COOKIE) {
959
1545
  }
960
1546
  return null;
961
1547
  }
1548
+
1549
+ // --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") -------------------------
1550
+ // The twins of fabric_service.systemd_unit / task_xml and their install and uninstall. `run(argv)`
1551
+ // returns spawnSync's {status, stdout, stderr}; the tests pass a recorder instead of the real tools.
1552
+
1553
+ const defaultRun = (argv) => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', windowsHide: true });
1554
+
1555
+ function plain(value, what) {
1556
+ // A supervisor file is line- and XML-structured: no control character in anything written into it.
1557
+ 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.`);
1558
+ return value;
1559
+ }
1560
+
1561
+ function noSecretInSupervisorFile(argv, environment) {
1562
+ // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
1563
+ for (const arg of argv) {
1564
+ 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).');
1565
+ }
1566
+ for (const [name, value] of Object.entries(environment ?? {})) {
1567
+ if (credentialName(name) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
1568
+ }
1569
+ }
1570
+
1571
+ function check(result, what) {
1572
+ if (result?.status !== 0) throw new ServiceError(`${what} failed: ${String(result?.stderr || result?.stdout || result?.error?.message || '').trim().slice(0, 200)}`);
1573
+ }
1574
+
1575
+ const systemdQuote = (value) => `"${plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
1576
+
1577
+ /**
1578
+ * A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
1579
+ * WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd).
1580
+ */
1581
+ export function systemdUnit(unit, programArguments, { workingDirectory, environment = {}, description, stopTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
1582
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1583
+ const argv = programArguments.map(String);
1584
+ if (!argv.length || !argv[0].startsWith('/')) throw new ServiceError('A systemd unit starts an absolute executable.');
1585
+ if (!(stopTimeout > DRAIN_SECONDS)) throw new ServiceError(`TimeoutStopSec must outlast the ${DRAIN_SECONDS} s drain.`);
1586
+ const env = { ...environment };
1587
+ 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.`);
1588
+ noSecretInSupervisorFile(argv, env);
1589
+ env[SUPERVISOR_ENV] ??= 'systemd';
1590
+ const cwd = plain(String(workingDirectory), 'The working directory');
1591
+ if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
1592
+ return [
1593
+ '[Unit]', `Description=${plain(description ?? unit, 'The description').replace(/%/g, '%%')}`, '',
1594
+ '[Service]', 'Type=simple', `ExecStart=${argv.map(systemdQuote).join(' ')}`,
1595
+ // Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
1596
+ `WorkingDirectory=${cwd.replace(/%/g, '%%')}`,
1597
+ ...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
1598
+ 'Restart=always', 'RestartSec=10', `TimeoutStopSec=${Math.trunc(stopTimeout)}`, 'KillMode=mixed', '',
1599
+ '[Install]', 'WantedBy=default.target', '',
1600
+ ].join('\n');
1601
+ }
1602
+
1603
+ const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
1604
+
1605
+ /**
1606
+ * Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
1607
+ * The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
1608
+ * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
1609
+ */
1610
+ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
1611
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
1612
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1613
+ fs.mkdirSync(unitDir, { recursive: true });
1614
+ const file = path.join(unitDir, unit);
1615
+ const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
1616
+ atomicWrite(file, text, 0o644);
1617
+ check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
1618
+ if (state === 'disabled' && !forceEnable) return file;
1619
+ check(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
1620
+ check(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
1621
+ return file;
1622
+ }
1623
+
1624
+ /** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
1625
+ export function systemdUninstall(unit, { unitDir = systemdDir(), run = defaultRun, platform = process.platform } = {}) {
1626
+ if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
1627
+ if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
1628
+ run(['systemctl', '--user', 'disable', '--now', unit]);
1629
+ fs.rmSync(path.join(unitDir, unit), { force: true });
1630
+ check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
1631
+ }
1632
+
1633
+ /** One command line from arguments, quoted the way the Microsoft C runtime parses it (Python's list2cmdline). */
1634
+ export function windowsCommandLine(args) {
1635
+ return args.map((arg) => {
1636
+ const quote = arg === '' || /[ \t]/.test(arg);
1637
+ let out = quote ? '"' : '';
1638
+ let slashes = 0;
1639
+ for (const c of arg) {
1640
+ if (c === '\\') { slashes += 1; continue; }
1641
+ if (c === '"') { out += '\\'.repeat(slashes * 2 + 1) + '"'; slashes = 0; continue; }
1642
+ out += '\\'.repeat(slashes) + c;
1643
+ slashes = 0;
1644
+ }
1645
+ return out + '\\'.repeat(quote ? slashes * 2 : slashes) + (quote ? '"' : '');
1646
+ }).join(' ');
1647
+ }
1648
+
1649
+ const xml = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1650
+
1651
+ /**
1652
+ * A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution
1653
+ * time limit, run only when the user is logged on, created without administrator rights.
1654
+ */
1655
+ export function taskXml(task, programArguments, { workingDirectory, description, user } = {}) {
1656
+ if (!TASK.test(task)) throw new ServiceError(`task must match ${TASK.source}`);
1657
+ const argv = programArguments.map(String);
1658
+ if (!argv.length || !/^[A-Za-z]:[\\/]/.test(argv[0])) throw new ServiceError('A task starts a drive-absolute executable (C:\\...).');
1659
+ for (const arg of argv) plain(arg, 'An argument');
1660
+ plain(String(workingDirectory), 'The working directory');
1661
+ plain(description ?? task, 'The description');
1662
+ noSecretInSupervisorFile(argv, {});
1663
+ const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
1664
+ if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
1665
+ plain(who, 'The user');
1666
+ return `<?xml version="1.0" encoding="UTF-16"?>
1667
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1668
+ <RegistrationInfo><Description>${xml(description ?? task)}</Description><URI>${xml(task)}</URI></RegistrationInfo>
1669
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml(who)}</UserId></LogonTrigger></Triggers>
1670
+ <Principals><Principal id="Author"><UserId>${xml(who)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
1671
+ <Settings>
1672
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1673
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1674
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1675
+ <StartWhenAvailable>true</StartWhenAvailable>
1676
+ <Enabled>true</Enabled>
1677
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1678
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
1679
+ </Settings>
1680
+ <Actions Context="Author"><Exec><Command>${xml(argv[0])}</Command><Arguments>${xml(windowsCommandLine(argv.slice(1)))}</Arguments><WorkingDirectory>${xml(workingDirectory)}</WorkingDirectory></Exec></Actions>
1681
+ </Task>
1682
+ `;
1683
+ }
1684
+
1685
+ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Enabled>/;
1686
+
1687
+ /**
1688
+ * Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
1689
+ * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
1690
+ */
1691
+ export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
1692
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
1693
+ const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
1694
+ const existed = query?.status === 0;
1695
+ const keepOff = existed && !forceEnable && TASK_DISABLED.test(String(query?.stdout ?? ''));
1696
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-task-'));
1697
+ const file = path.join(dir, 'task.xml');
1698
+ try {
1699
+ // schtasks reads the encoding the declaration names: UTF-16 little-endian with its byte order mark.
1700
+ fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xmlText, 'utf16le')]));
1701
+ check(run(['schtasks', '/Create', '/TN', task, '/XML', file, '/F']), 'schtasks /Create');
1702
+ } finally {
1703
+ fs.rmSync(dir, { recursive: true, force: true });
1704
+ }
1705
+ if (keepOff) {
1706
+ check(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
1707
+ return;
1708
+ }
1709
+ if (existed) run(['schtasks', '/End', '/TN', task]);
1710
+ check(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
1711
+ }
1712
+
1713
+ /** End the running task, then delete it. */
1714
+ export function taskUninstall(task, { run = defaultRun, platform = process.platform } = {}) {
1715
+ if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
1716
+ run(['schtasks', '/End', '/TN', task]);
1717
+ check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
1718
+ }