@addai/node 0.5.1 → 0.6.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.
@@ -0,0 +1,62 @@
1
+ import { AutostartBackend, AutostartEntry } from './autostart';
2
+ export declare const PLIST_PATH: string;
3
+ export interface PlistSpec {
4
+ label: string;
5
+ entry: AutostartEntry;
6
+ env: Record<string, string>;
7
+ logPath: string;
8
+ workingDirectory: string;
9
+ }
10
+ /**
11
+ * The plist, as a string. Pure — this is what the tests assert on.
12
+ *
13
+ * KeepAlive is `true`, not `{SuccessfulExit: false}`: the node is meant to be
14
+ * always-on, and a remote roll relies on it. A supervised `update_runtime`
15
+ * installs the new version and simply EXITS; launchd starting the replacement
16
+ * is what makes that safe (see index.ts). ThrottleInterval keeps a crash loop
17
+ * to one launch per 10s instead of a spin.
18
+ *
19
+ * ProcessType is Interactive because Background jobs get throttled CPU
20
+ * scheduling — a daemon that supervises agent runs is not a batch job.
21
+ */
22
+ export declare function buildPlist(spec: PlistSpec): string;
23
+ /** Pull ProgramArguments back out of a plist we wrote, for `status`. */
24
+ export declare function parsePlistEntry(xml: string): AutostartEntry | undefined;
25
+ /** Directories macOS gates behind user consent no background agent can give. */
26
+ export declare function protectedPathReason(scriptPath: string, home?: string): string | null;
27
+ export declare function protectedPathError(label: string, scriptPath: string): string;
28
+ /**
29
+ * Hand the node over to launchd.
30
+ *
31
+ * A daemon started by hand holds the lockfile, and `acquireLockfile()` throws
32
+ * for anyone else. Bootstrapping the agent underneath it would therefore mean
33
+ * launchd relaunching a job that exits 1 every ten seconds until the human
34
+ * closes their terminal. So we ask the incumbent to stand down first — SIGTERM
35
+ * runs its normal drain, so live entity turns finish rather than being cut.
36
+ *
37
+ * Except when the incumbent is US. `ainode startup enable` can be pressed from
38
+ * inside the console of the very daemon it is arming, and signalling ourselves
39
+ * there would kill the node the user is looking at. That case writes the plist
40
+ * and stops: ~/Library/LaunchAgents is read at every login, so the change
41
+ * lands at the next one without anybody being killed for it.
42
+ */
43
+ export type Handover =
44
+ /** Nothing was running — safe to start the agent immediately. */
45
+ {
46
+ kind: 'none';
47
+ }
48
+ /** The running daemon is this very process. Write only, don't bootstrap. */
49
+ | {
50
+ kind: 'self';
51
+ }
52
+ /** Someone else's daemon stood down; the agent can take over now. */
53
+ | {
54
+ kind: 'stopped';
55
+ }
56
+ /** It was asked to stop and didn't. */
57
+ | {
58
+ kind: 'stuck';
59
+ pid: number;
60
+ };
61
+ export declare function stopRunningDaemon(timeoutMs?: number, selfPid?: number): Handover;
62
+ export declare const backend: AutostartBackend;
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+ // macOS backend: a per-user LaunchAgent.
3
+ //
4
+ // ~/Library/LaunchAgents is loaded by launchd at every login, so the plist
5
+ // existing IS the answer to "does this node start at login?" — no daemon, no
6
+ // admin rights, no stored password.
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.backend = exports.PLIST_PATH = void 0;
42
+ exports.buildPlist = buildPlist;
43
+ exports.parsePlistEntry = parsePlistEntry;
44
+ exports.protectedPathReason = protectedPathReason;
45
+ exports.protectedPathError = protectedPathError;
46
+ exports.stopRunningDaemon = stopRunningDaemon;
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ const child_process_1 = require("child_process");
51
+ const autostart_1 = require("./autostart");
52
+ const lockfile_1 = require("./lockfile");
53
+ exports.PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${autostart_1.LABEL}.plist`);
54
+ /** `<string>` bodies are the only place user data lands in the plist, and a
55
+ * PATH entry with an `&` in it would otherwise produce invalid XML that
56
+ * launchd rejects at login — silently, from the user's point of view. */
57
+ function esc(s) {
58
+ return s
59
+ .replace(/&/g, '&amp;')
60
+ .replace(/</g, '&lt;')
61
+ .replace(/>/g, '&gt;');
62
+ }
63
+ /**
64
+ * The plist, as a string. Pure — this is what the tests assert on.
65
+ *
66
+ * KeepAlive is `true`, not `{SuccessfulExit: false}`: the node is meant to be
67
+ * always-on, and a remote roll relies on it. A supervised `update_runtime`
68
+ * installs the new version and simply EXITS; launchd starting the replacement
69
+ * is what makes that safe (see index.ts). ThrottleInterval keeps a crash loop
70
+ * to one launch per 10s instead of a spin.
71
+ *
72
+ * ProcessType is Interactive because Background jobs get throttled CPU
73
+ * scheduling — a daemon that supervises agent runs is not a batch job.
74
+ */
75
+ function buildPlist(spec) {
76
+ const args = [spec.entry.file, ...spec.entry.args]
77
+ .map(a => ` <string>${esc(a)}</string>`)
78
+ .join('\n');
79
+ const env = Object.entries(spec.env)
80
+ .map(([k, v]) => ` <key>${esc(k)}</key>\n <string>${esc(v)}</string>`)
81
+ .join('\n');
82
+ return `<?xml version="1.0" encoding="UTF-8"?>
83
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
84
+ <plist version="1.0">
85
+ <dict>
86
+ <key>Label</key>
87
+ <string>${esc(spec.label)}</string>
88
+ <key>ProgramArguments</key>
89
+ <array>
90
+ ${args}
91
+ </array>
92
+ <key>RunAtLoad</key>
93
+ <true/>
94
+ <key>KeepAlive</key>
95
+ <true/>
96
+ <key>ThrottleInterval</key>
97
+ <integer>10</integer>
98
+ <key>ProcessType</key>
99
+ <string>Interactive</string>
100
+ <key>WorkingDirectory</key>
101
+ <string>${esc(spec.workingDirectory)}</string>
102
+ <key>StandardOutPath</key>
103
+ <string>${esc(spec.logPath)}</string>
104
+ <key>StandardErrorPath</key>
105
+ <string>${esc(spec.logPath)}</string>
106
+ <key>EnvironmentVariables</key>
107
+ <dict>
108
+ ${env}
109
+ </dict>
110
+ </dict>
111
+ </plist>
112
+ `;
113
+ }
114
+ /** Pull ProgramArguments back out of a plist we wrote, for `status`. */
115
+ function parsePlistEntry(xml) {
116
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml);
117
+ if (!block)
118
+ return undefined;
119
+ const items = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)]
120
+ .map(m => m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&'));
121
+ if (!items.length)
122
+ return undefined;
123
+ return { file: items[0], args: items.slice(1) };
124
+ }
125
+ /* ── TCC: the failure that looks like success ────────────────────────────
126
+ * Found by running this for real. A LaunchAgent pinned to a script under
127
+ * ~/Documents starts, reports `state = running` with a healthy pid, writes
128
+ * nothing, and serves nothing — because macOS privacy protection blocks the
129
+ * open() and the process HANGS in module resolution rather than failing.
130
+ * `launchctl print` says the job is fine. It is not.
131
+ *
132
+ * That is the worst possible failure for this feature: the node looks alive
133
+ * to every tool including our own, so refuse to arm it instead. Our fleet's
134
+ * checkouts live in ~/Documents/GitHub, so this is the default case here, not
135
+ * an edge one.
136
+ */
137
+ /** Directories macOS gates behind user consent no background agent can give. */
138
+ function protectedPathReason(scriptPath, home = os.homedir()) {
139
+ const p = scriptPath.replace(/\/+$/, '');
140
+ const under = (dir) => p === dir || p.startsWith(dir + path.sep);
141
+ const guarded = [
142
+ [path.join(home, 'Documents'), '~/Documents'],
143
+ [path.join(home, 'Desktop'), '~/Desktop'],
144
+ [path.join(home, 'Downloads'), '~/Downloads'],
145
+ [path.join(home, 'Library', 'Mobile Documents'), 'iCloud Drive'],
146
+ ];
147
+ for (const [dir, label] of guarded)
148
+ if (under(dir))
149
+ return label;
150
+ if (under('/Volumes'))
151
+ return 'an external volume';
152
+ return null;
153
+ }
154
+ function protectedPathError(label, scriptPath) {
155
+ return [
156
+ `macOS will not let a login agent read ${label}, and this node runs from there:`,
157
+ ` ${scriptPath}`,
158
+ '',
159
+ 'It would not fail loudly — the agent starts, reports itself healthy, and hangs',
160
+ 'forever on the first file it tries to read. So this refuses instead.',
161
+ '',
162
+ 'Fix it either way:',
163
+ ' npm i -g @addai/node install outside the protected folder, then re-run this',
164
+ ' — or move the checkout somewhere like ~/src and start the node from there',
165
+ '',
166
+ 'If you have already granted Full Disk Access to node, re-run with --force.',
167
+ ].join('\n');
168
+ }
169
+ function domain() {
170
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
171
+ return `gui/${uid}`;
172
+ }
173
+ function launchctl(args) {
174
+ try {
175
+ // stderr is piped, not inherited: `bootout` on a label that isn't loaded
176
+ // prints "Boot-out failed: 3: No such process", and that expected no-op
177
+ // was landing in the user's terminal above our own success message.
178
+ const out = (0, child_process_1.execFileSync)('launchctl', args, {
179
+ encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe'],
180
+ });
181
+ return { ok: true, output: out.toString().trim() };
182
+ }
183
+ catch (err) {
184
+ const e = err;
185
+ const stderr = e.stderr ? e.stderr.toString().trim() : '';
186
+ return { ok: false, output: stderr || e.message || 'launchctl failed' };
187
+ }
188
+ }
189
+ function stopRunningDaemon(timeoutMs = 40_000, selfPid = process.pid) {
190
+ const lock = (0, lockfile_1.readLockfile)();
191
+ if (!lock || !(0, lockfile_1.lockfileAlive)(lock))
192
+ return { kind: 'none' };
193
+ if (lock.pid === selfPid)
194
+ return { kind: 'self' };
195
+ try {
196
+ process.kill(lock.pid, 'SIGTERM');
197
+ }
198
+ catch {
199
+ return { kind: 'stopped' };
200
+ }
201
+ // Synchronous poll: `startup enable` is a one-shot CLI command, and making
202
+ // the whole backend async to sleep 250ms would buy nothing.
203
+ const deadline = Date.now() + timeoutMs;
204
+ const nap = new Int32Array(new SharedArrayBuffer(4));
205
+ while (Date.now() < deadline) {
206
+ Atomics.wait(nap, 0, 0, 250);
207
+ if (!(0, lockfile_1.lockfileAlive)((0, lockfile_1.readLockfile)()))
208
+ return { kind: 'stopped' };
209
+ }
210
+ return { kind: 'stuck', pid: lock.pid };
211
+ }
212
+ function enable(entry, env, logPath, opts = {}) {
213
+ const warnings = [];
214
+ const script = entry.args[0] ?? entry.file;
215
+ const guarded = protectedPathReason(script);
216
+ if (guarded && !opts.force) {
217
+ return { ok: false, warnings, error: protectedPathError(guarded, script) };
218
+ }
219
+ if (guarded)
220
+ warnings.push(`forced past macOS protection on ${guarded} — if the node hangs at login, that is why`);
221
+ fs.mkdirSync(path.dirname(exports.PLIST_PATH), { recursive: true });
222
+ fs.writeFileSync(exports.PLIST_PATH, buildPlist({
223
+ label: autostart_1.LABEL,
224
+ entry,
225
+ env,
226
+ logPath,
227
+ workingDirectory: os.homedir(),
228
+ }), { mode: 0o644 });
229
+ // Unload any previous copy of the agent BEFORE looking for a daemon to hand
230
+ // over from. Order matters: an already-supervised job that we SIGTERM while
231
+ // it is still loaded gets restarted by KeepAlive within the second, and the
232
+ // handover would sit there watching a lockfile that keeps coming back and
233
+ // conclude the node is stuck. Booting out first makes the stop stick.
234
+ launchctl(['bootout', `${domain()}/${autostart_1.LABEL}`]);
235
+ const handover = stopRunningDaemon();
236
+ if (handover.kind === 'stuck') {
237
+ return {
238
+ ok: false,
239
+ path: exports.PLIST_PATH,
240
+ warnings,
241
+ error: `a node is already running here (pid ${handover.pid}) and did not stop within 40s — quit it, then run \`ainode startup enable\` again`,
242
+ };
243
+ }
244
+ if (handover.kind === 'self') {
245
+ // Deliberately no bootstrap: launchd would start a second daemon, which
246
+ // would fail to take the lockfile and be relaunched every 10s for as long
247
+ // as this one lives. The plist on disk is enough.
248
+ warnings.push('this node is running right now, so launchd takes over at your next login (or when you restart it)');
249
+ return { ok: true, path: exports.PLIST_PATH, warnings };
250
+ }
251
+ if (handover.kind === 'stopped') {
252
+ warnings.push('stopped the node that was already running; launchd owns it now');
253
+ }
254
+ let load = launchctl(['bootstrap', domain(), exports.PLIST_PATH]);
255
+ if (!load.ok) {
256
+ // Pre-Yosemite verbs still work on every macOS we support and are the
257
+ // documented fallback when bootstrap is unavailable or refuses.
258
+ const legacy = launchctl(['load', '-w', exports.PLIST_PATH]);
259
+ if (!legacy.ok) {
260
+ return { ok: false, path: exports.PLIST_PATH, warnings, error: `launchctl could not load the agent: ${load.output}` };
261
+ }
262
+ load = legacy;
263
+ }
264
+ return { ok: true, path: exports.PLIST_PATH, warnings };
265
+ }
266
+ function disable() {
267
+ // Order matters: bootout while the plist still exists, or launchd has
268
+ // nothing to look up. This also STOPS a running supervised daemon — the
269
+ // node goes offline, which is what "stop starting at login" has to mean
270
+ // when the thing is running right now.
271
+ const out = launchctl(['bootout', `${domain()}/${autostart_1.LABEL}`]);
272
+ // Only fall back to the legacy verb if the modern one didn't do the job —
273
+ // running both unconditionally printed "Unload failed: 5" after a perfectly
274
+ // successful bootout.
275
+ if (!out.ok && fs.existsSync(exports.PLIST_PATH))
276
+ launchctl(['unload', '-w', exports.PLIST_PATH]);
277
+ try {
278
+ fs.unlinkSync(exports.PLIST_PATH);
279
+ }
280
+ catch { /* already gone */ }
281
+ return { ok: true };
282
+ }
283
+ function status() {
284
+ if (!fs.existsSync(exports.PLIST_PATH)) {
285
+ return { supported: true, enabled: false, supervisor: 'launchd' };
286
+ }
287
+ // Deliberately no `launchctl print` here: status() rides every heartbeat,
288
+ // and the plist sitting in ~/Library/LaunchAgents is already the whole
289
+ // answer to "will this start at login?" — launchd loads that directory at
290
+ // every login regardless of what is loaded right now.
291
+ const xml = fs.readFileSync(exports.PLIST_PATH, 'utf8');
292
+ const entry = parsePlistEntry(xml);
293
+ const issues = [];
294
+ if (entry && !fs.existsSync(entry.args[0] ?? entry.file)) {
295
+ issues.push(`the pinned program is gone (${entry.args[0] ?? entry.file}) — re-run \`ainode startup enable\``);
296
+ }
297
+ return {
298
+ supported: true,
299
+ enabled: true,
300
+ supervisor: 'launchd',
301
+ entry,
302
+ path: exports.PLIST_PATH,
303
+ ...(issues.length ? { issues } : {}),
304
+ };
305
+ }
306
+ exports.backend = { supervisor: 'launchd', status, enable, disable };
@@ -0,0 +1,46 @@
1
+ import { AutostartBackend, AutostartEntry } from './autostart';
2
+ export declare const STARTUP_DIR: string;
3
+ export declare const CMD_PATH: string;
4
+ export declare const VBS_PATH: string;
5
+ export declare const XML_PATH: string;
6
+ /** The account the task runs as. `DOMAIN\User` when we have a domain — a bare
7
+ * username is ambiguous on a machine joined to one. */
8
+ export declare function currentUserId(env?: NodeJS.ProcessEnv): string;
9
+ /**
10
+ * The batch file that actually starts the node.
11
+ *
12
+ * Environment and output redirection live here, in a file where batch quoting
13
+ * behaves, instead of being wedged into a VBS string or a `schtasks` argument.
14
+ * The redirect matters: headless stdout on Windows has nowhere else to go, and
15
+ * a node whose only account of itself is missing is a node nobody can debug.
16
+ */
17
+ export declare function buildCmd(entry: AutostartEntry, env: Record<string, string>, logPath: string): string;
18
+ /**
19
+ * The window-hiding shim.
20
+ *
21
+ * A task running under an InteractiveToken shows a console window for every
22
+ * launch; with the logon trigger repeating, that would be a window popping up
23
+ * on the user's desktop every five minutes. `WScript.Shell.Run(…, 0, False)`
24
+ * starts the batch file with no window and does not wait for it.
25
+ */
26
+ export declare function buildVbs(cmdPath: string): string;
27
+ export interface TaskSpec {
28
+ userId: string;
29
+ vbsPath: string;
30
+ wscriptPath: string;
31
+ /** ISO-8601 local timestamp for the task's Date field. */
32
+ createdAt: string;
33
+ }
34
+ /**
35
+ * The task definition.
36
+ *
37
+ * The repetition is what stands in for launchd's KeepAlive. Task Scheduler
38
+ * cannot supervise this process — the shim returns instantly, so the task is
39
+ * "finished" a second after it starts and RestartOnFailure never applies. So
40
+ * the logon trigger repeats every five minutes and the action is
41
+ * `run --ensure`, which exits immediately when a live daemon already owns the
42
+ * node. Crash recovery therefore costs at most five minutes and never stacks
43
+ * a second daemon.
44
+ */
45
+ export declare function buildTaskXml(spec: TaskSpec): string;
46
+ export declare const backend: AutostartBackend;