@passioncode-ai/fabric-agent-adapter 0.29.0 → 0.31.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.
@@ -2328,3 +2328,185 @@ export function taskUninstall(task, { run = defaultRun, platform = process.platf
2328
2328
  run(['schtasks', '/End', '/TN', task]);
2329
2329
  check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
2330
2330
  }
2331
+
2332
+ // --- launchd (macOS; lifecycle.md "Install, upgrade, uninstall", LC-14; FAA-04) ------------------------------------
2333
+ // The twins of fabric_service.launchd_plist / launchd_loaded / launchd_override / launchd_install / launchd_uninstall.
2334
+ // `run(argv)` returns spawnSync's {status, stdout, stderr}; the tests pass a launchd stand-in, never the real launchctl.
2335
+
2336
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,250}$/;
2337
+ const DEFAULT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
2338
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2339
+
2340
+ function launchdLabel(label) {
2341
+ if (!LABEL.test(String(label))) throw new ServiceError(`label must match ${LABEL.source}`);
2342
+ return String(label);
2343
+ }
2344
+
2345
+ function plistValue(value, depth) {
2346
+ // plistlib.dumps's layout, byte for byte: one tab per level, keys sorted, & < > escaped, empty containers self-closed.
2347
+ const pad = '\t'.repeat(depth);
2348
+ if (value === true || value === false) return `${pad}<${value}/>\n`;
2349
+ if (Number.isInteger(value)) return `${pad}<integer>${value}</integer>\n`;
2350
+ if (typeof value === 'string') return `${pad}<string>${plain(value, 'A plist string').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</string>\n`;
2351
+ if (Array.isArray(value)) return value.length ? `${pad}<array>\n${value.map((v) => plistValue(v, depth + 1)).join('')}${pad}</array>\n` : `${pad}<array/>\n`;
2352
+ const keys = Object.keys(value).sort();
2353
+ if (!keys.length) return `${pad}<dict/>\n`;
2354
+ const inner = keys.map((k) => `${pad}\t<key>${plain(k, 'A plist key').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</key>\n${plistValue(value[k], depth + 1)}`).join('');
2355
+ return `${pad}<dict>\n${inner}${pad}</dict>\n`;
2356
+ }
2357
+
2358
+ /**
2359
+ * The launchd job: RunAtLoad + KeepAlive true + ThrottleInterval 10, ProcessType Standard, ExitTimeOut above the drain.
2360
+ * FABRIC_SERVICE_SUPERVISOR=launchd tells the lock to back off instead of exiting into a respawn loop (DEC-0057).
2361
+ * No secret is written: an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or an
2362
+ * argument shaped like a credential is refused. Returns the XML text, the same bytes Python's plistlib writes.
2363
+ */
2364
+ export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
2365
+ launchdLabel(label);
2366
+ const argv = programArguments.map(String);
2367
+ if (!argv.length) throw new ServiceError('A launchd job starts a program; ProgramArguments is empty.');
2368
+ if (!(exitTimeout > DRAIN_SECONDS)) throw new ServiceError(`ExitTimeOut must outlast the ${DRAIN_SECONDS} s drain.`);
2369
+ if (workingDirectory === undefined || stdoutPath === undefined) throw new ServiceError('A launchd job names its working directory and its log file.');
2370
+ const env = { ...environment };
2371
+ for (const [name, value] of Object.entries(env)) {
2372
+ if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
2373
+ if (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
2374
+ }
2375
+ for (const arg of argv) {
2376
+ if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
2377
+ }
2378
+ noMarkerArgument(argv);
2379
+ if (env.PATH === undefined) env.PATH = DEFAULT_PATH;
2380
+ env[SUPERVISOR_ENV] = 'launchd'; // the plist's writer writes the marker, whatever the caller passed
2381
+ const job = {
2382
+ Label: label,
2383
+ ProgramArguments: argv,
2384
+ WorkingDirectory: String(workingDirectory),
2385
+ RunAtLoad: true,
2386
+ KeepAlive: true,
2387
+ ThrottleInterval: 10,
2388
+ ExitTimeOut: Math.trunc(exitTimeout),
2389
+ // Standard, never Background: macOS starves a Background job under load and every host then reports an outage.
2390
+ ProcessType: 'Standard',
2391
+ StandardOutPath: String(stdoutPath),
2392
+ StandardErrorPath: String(stdoutPath),
2393
+ EnvironmentVariables: env,
2394
+ };
2395
+ return '<?xml version="1.0" encoding="UTF-8"?>\n'
2396
+ + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
2397
+ + `<plist version="1.0">\n${plistValue(job, 0)}</plist>\n`;
2398
+ }
2399
+
2400
+ function launchdDomain(platform, uid) {
2401
+ // DEC-0032: launchd is macOS's supervisor; Linux has systemd and Windows Task Scheduler.
2402
+ if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
2403
+ return `gui/${uid ?? process.getuid()}`;
2404
+ }
2405
+
2406
+ /** True when launchd has the job loaded (`launchctl print gui/<uid>/<label>` answers). */
2407
+ export function launchdLoaded(label, { run = defaultRun, platform = process.platform, uid } = {}) {
2408
+ return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
2409
+ }
2410
+
2411
+ const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
2412
+
2413
+ /**
2414
+ * The operator's launchd override for `label`: 'disabled', 'enabled', null when none is recorded (a first install), or
2415
+ * 'unknown' when the table cannot be read. macOS 14+ prints `=> disabled|enabled`, older releases `=> true|false`.
2416
+ */
2417
+ export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid } = {}) {
2418
+ const result = run(['launchctl', 'print-disabled', launchdDomain(platform, uid)]);
2419
+ if (result?.status !== 0) return 'unknown';
2420
+ for (const line of String(result.stdout ?? '').split('\n')) {
2421
+ const match = OVERRIDE_ROW.exec(line);
2422
+ if (match && match[1] === label) return ['disabled', 'true'].includes(match[2]) ? 'disabled' : 'enabled';
2423
+ }
2424
+ return null;
2425
+ }
2426
+
2427
+ async function wellKnownOf(origin) {
2428
+ try {
2429
+ const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
2430
+ return response.ok ? await response.json() : null;
2431
+ } catch {
2432
+ return null;
2433
+ }
2434
+ }
2435
+
2436
+ /**
2437
+ * Write and lint the plist, bootout and WAIT for the unload, bootstrap (retrying launchd's EIO 5 times), then poll the
2438
+ * well-known document until it answers with this identity; resolves to that document.
2439
+ *
2440
+ * The operator's intent wins (LC-14): only a first install (no override recorded) enables the label. A label the
2441
+ * operator disabled stays disabled and unloaded — the plist is still rewritten, so the new release is what starts once
2442
+ * they turn it back on — and the answer is {disabled: true, label, plist, loaded}. `forceEnable` is for an operator
2443
+ * who asked for the service to be switched on.
2444
+ */
2445
+ export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
2446
+ run = defaultRun, platform = process.platform, uid, fetchWellKnown = wellKnownOf, sleep = pause } = {}) {
2447
+ const domain = launchdDomain(platform, uid);
2448
+ launchdLabel(label);
2449
+ if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
2450
+ const opts = { run, platform, uid };
2451
+ atomicWrite(plistPath, plistText, 0o644);
2452
+ const lint = run(['plutil', '-lint', String(plistPath)]);
2453
+ if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
2454
+ const target = `${domain}/${label}`;
2455
+ const override = launchdOverride(label, opts);
2456
+ if (forceEnable || override === null) run(['launchctl', 'enable', target]);
2457
+ else if (override === 'disabled') return { disabled: true, label, plist: String(plistPath), loaded: launchdLoaded(label, opts) };
2458
+ run(['launchctl', 'bootout', target]);
2459
+ const unloaded = Date.now() + 15_000;
2460
+ while (launchdLoaded(label, opts) && Date.now() < unloaded) await sleep(250);
2461
+ let last = '';
2462
+ for (let attempt = 0; ; attempt += 1) {
2463
+ const result = run(['launchctl', 'bootstrap', domain, String(plistPath)]);
2464
+ if (result?.status === 0) break;
2465
+ last = String(result?.stderr || result?.stdout || '').trim();
2466
+ if (attempt === 4) throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
2467
+ await sleep(1000);
2468
+ }
2469
+ const deadline = Date.now() + timeout * 1000;
2470
+ while (Date.now() < deadline) {
2471
+ const doc = await fetchWellKnown(origin);
2472
+ if (doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
2473
+ await sleep(500);
2474
+ }
2475
+ throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
2476
+ }
2477
+
2478
+ /**
2479
+ * Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
2480
+ * override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
2481
+ * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
2482
+ * loaded at `timeout` seconds throws, leaving the plist and the data in place.
2483
+ */
2484
+ export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
2485
+ const domain = launchdDomain(platform, uid);
2486
+ launchdLabel(label);
2487
+ if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
2488
+ const dirs = purge ? serviceDirs(serviceId, platform) : {};
2489
+ const opts = { run, platform, uid };
2490
+ const target = `${domain}/${label}`;
2491
+ run(['launchctl', 'bootout', target]);
2492
+ const deadline = Date.now() + timeout * 1000;
2493
+ while (launchdLoaded(label, opts)) {
2494
+ if (Date.now() >= deadline) throw new ServiceError(`launchd job ${label} is still loaded ${timeout} s after bootout; nothing was removed.`);
2495
+ await sleep(100);
2496
+ }
2497
+ let plistRemoved = true;
2498
+ try {
2499
+ fs.unlinkSync(plistPath);
2500
+ } catch (error) {
2501
+ if (error.code !== 'ENOENT') throw error;
2502
+ plistRemoved = false;
2503
+ }
2504
+ run(['launchctl', 'enable', target]);
2505
+ const purged = [];
2506
+ for (const dir of Object.values(dirs)) {
2507
+ try { fs.lstatSync(dir); } catch { continue; }
2508
+ fs.rmSync(dir, { recursive: true, force: true }); // a symlinked folder: the link goes, never what it points at
2509
+ purged.push(dir);
2510
+ }
2511
+ return { unloaded: true, plistRemoved, override: 'enabled', purged };
2512
+ }