@kortix/agent-tunnel 0.13.41 → 0.13.43

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.
@@ -4,7 +4,7 @@ import { homedir, platform, userInfo } from 'os';
4
4
  import { dirname, join } from 'path';
5
5
  import { powershellQuote, shellQuote, xmlEscape } from './service-quoting';
6
6
  import type { ServicePaths } from './service-paths';
7
- import { SERVICE_LABEL, TERMINAL_SERVICE_EXIT_CODE, getServicePaths } from './service-paths';
7
+ import { getServicePaths } from './service-paths';
8
8
 
9
9
  /**
10
10
  * One driver per supervisor.
@@ -17,10 +17,16 @@ import { SERVICE_LABEL, TERMINAL_SERVICE_EXIT_CODE, getServicePaths } from './se
17
17
  export interface ServiceDriver {
18
18
  /** File that proves the service is installed, and is shown to the user. */
19
19
  unitPath(paths: ServicePaths): string;
20
+ /** The unit file contents `install` writes for this runner. */
21
+ render(paths: ServicePaths, runner: RunnerParts): string;
20
22
  install(paths: ServicePaths, runner: RunnerParts): Outcome;
21
23
  uninstall(paths: ServicePaths): Outcome;
22
24
  start(paths: ServicePaths, installed: boolean): Outcome;
23
25
  stop(paths: ServicePaths, installed: boolean): Outcome;
26
+ /** R3: stop AND keep it stopped across login and reboot, until resume. */
27
+ pause(paths: ServicePaths, installed: boolean): Outcome;
28
+ /** Reverses pause: enable, load, start. */
29
+ resume(paths: ServicePaths, installed: boolean): Outcome;
24
30
  status(paths: ServicePaths, installed: boolean): Outcome;
25
31
  }
26
32
 
@@ -28,6 +34,8 @@ export interface ServiceDriver {
28
34
  export interface RunnerParts {
29
35
  command: string;
30
36
  args: string[];
37
+ /** Extra environment for the supervised process (AGENT_TUNNEL_HOME, ELECTRON_RUN_AS_NODE). */
38
+ env?: Record<string, string>;
31
39
  }
32
40
 
33
41
  /**
@@ -37,13 +45,23 @@ export interface RunnerParts {
37
45
  */
38
46
  export function posixShellCommand(runner: RunnerParts): string {
39
47
  const interpreter = `"$(command -v ${shellQuote(runner.command)} 2>/dev/null || command -v node)"`;
40
- return `exec ${interpreter} ${runner.args.map(shellQuote).join(' ')}`;
48
+ // Exported inside the command, after the login shell's profile has run, so
49
+ // one rendering serves launchd and systemd alike and no profile can drop it.
50
+ const env = Object.entries(runner.env ?? {});
51
+ const exports = env.length
52
+ ? `export ${env.map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ')}; `
53
+ : '';
54
+ return `${exports}exec ${interpreter} ${runner.args.map(shellQuote).join(' ')}`;
41
55
  }
42
56
 
43
57
  export interface Outcome {
44
58
  /** `null` means "requested, but the supervisor did not confirm". */
45
59
  active?: boolean | null;
46
60
  installed?: boolean;
61
+ /** False while paused (disabled in the supervisor). */
62
+ enabled?: boolean;
63
+ /** Status only: the unit on disk equals what install would write now. */
64
+ upToDate?: boolean;
47
65
  detail?: string;
48
66
  }
49
67
 
@@ -52,16 +70,23 @@ interface CommandResult {
52
70
  detail: string;
53
71
  }
54
72
 
55
- function run(command: string, args: string[]): CommandResult {
56
- const result = spawnSync(command, args, { encoding: 'utf8' });
57
- return {
58
- ok: result.status === 0,
59
- detail: [result.stdout, result.stderr].filter(Boolean).join('\n').trim(),
60
- };
61
- }
73
+ /** The one place a supervisor command runs. Replaced in tests. */
74
+ export const supervisor = {
75
+ run(command: string, args: string[]): CommandResult {
76
+ const result = spawnSync(command, args, { encoding: 'utf8' });
77
+ return {
78
+ ok: result.status === 0,
79
+ detail: [result.stdout, result.stderr].filter(Boolean).join('\n').trim(),
80
+ };
81
+ },
82
+ };
83
+ const run = (command: string, args: string[]): CommandResult => supervisor.run(command, args);
62
84
 
63
85
  const notInstalled = (what: string): CommandResult => ({ ok: false, detail: `${what} is not installed.` });
64
86
 
87
+ /** Blocking pause between supervisor retries. Replaced in tests. */
88
+ export const pause = { ms(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } };
89
+
65
90
  function joinDetails(...results: CommandResult[]): string {
66
91
  return results.map((result) => result.detail).filter(Boolean).join('\n');
67
92
  }
@@ -82,14 +107,14 @@ function logPaths(paths: ServicePaths): { stdout: string; stderr: string } {
82
107
 
83
108
  export function renderLaunchdPlist(command: string, paths: ServicePaths = getServicePaths()): string {
84
109
  const { stdout, stderr } = logPaths(paths);
85
- // KeepAlive is conditional: a clean exit means the agent stopped for a reason
86
- // restarting cannot fix, such as a missing or revoked credential.
110
+ // KeepAlive is unconditional (R3): the agent never exits on its own for a bad
111
+ // credential any more, so any exit is a crash. Pause is `launchctl disable`.
87
112
  return `<?xml version="1.0" encoding="UTF-8"?>
88
113
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
89
114
  <plist version="1.0">
90
115
  <dict>
91
116
  <key>Label</key>
92
- <string>${xmlEscape(SERVICE_LABEL)}</string>
117
+ <string>${xmlEscape(paths.label)}</string>
93
118
  <key>ProgramArguments</key>
94
119
  <array>
95
120
  <string>/bin/sh</string>
@@ -99,10 +124,9 @@ export function renderLaunchdPlist(command: string, paths: ServicePaths = getSer
99
124
  <key>RunAtLoad</key>
100
125
  <true/>
101
126
  <key>KeepAlive</key>
102
- <dict>
103
- <key>SuccessfulExit</key>
104
- <false/>
105
- </dict>
127
+ <true/>
128
+ <key>ThrottleInterval</key>
129
+ <integer>10</integer>
106
130
  <key>Umask</key>
107
131
  <integer>63</integer>
108
132
  <key>StandardOutPath</key>
@@ -132,7 +156,7 @@ Wants=network-online.target
132
156
  Type=simple
133
157
  UMask=0077
134
158
  ExecStart=/bin/sh -lc ${shellQuote(command)}
135
- Restart=on-failure
159
+ Restart=always
136
160
  RestartSec=5
137
161
  WorkingDirectory=${homedir()}
138
162
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
@@ -144,15 +168,18 @@ WantedBy=default.target
144
168
  `;
145
169
  }
146
170
 
147
- export function renderWindowsPowerShellScript(runner: { command: string; args: string[] }): string {
171
+ export function renderWindowsPowerShellScript(runner: RunnerParts): string {
148
172
  const command = powershellQuote(runner.command);
149
173
  const args = runner.args.map(powershellQuote).join(' ');
174
+ const env = Object.entries(runner.env ?? {})
175
+ .map(([key, value]) => `$env:${key} = ${powershellQuote(value)}\n`)
176
+ .join('');
150
177
  return `$ErrorActionPreference = 'Continue'
151
- while ($true) {
152
- & ${command}${args ? ` ${args}` : ''}
153
- # A clean exit means the agent stopped for a reason restarting cannot fix,
154
- # such as a missing or revoked credential. Anything else is a crash worth retrying.
155
- if ($LASTEXITCODE -eq ${TERMINAL_SERVICE_EXIT_CODE}) { break }
178
+ ${env}while ($true) {
179
+ # The pipe makes PowerShell wait for a GUI-subsystem binary (the desktop
180
+ # app's Kortix.exe) and set $LASTEXITCODE; a bare call returns at once.
181
+ & ${command}${args ? ` ${args}` : ''} | Out-Null
182
+ # The agent never exits on its own (R2), so every exit is restarted.
156
183
  Start-Sleep -Seconds 5
157
184
  }
158
185
  `;
@@ -162,14 +189,27 @@ while ($true) {
162
189
 
163
190
  const launchd: ServiceDriver = {
164
191
  unitPath: (paths) => paths.launchdPlist,
192
+ render: (paths, runner) => renderLaunchdPlist(posixShellCommand(runner), paths),
165
193
 
166
194
  install(paths, runner) {
167
195
  mkdirSync(dirname(paths.launchdPlist), { recursive: true });
168
- writeFileSync(paths.launchdPlist, renderLaunchdPlist(posixShellCommand(runner), paths), { mode: 0o600 });
196
+ writeFileSync(paths.launchdPlist, launchd.render(paths, runner), { mode: 0o600 });
169
197
  run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]);
170
- const boot = run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist]);
171
- const kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${SERVICE_LABEL}`]);
172
- return { installed: true, active: boot.ok || kick.ok ? true : null, detail: joinDetails(boot, kick) };
198
+ run('launchctl', ['enable', `${launchdTarget()}/${paths.label}`]);
199
+ // `bootstrap` right after `bootout` often fails with "5: Input/output error"
200
+ // while launchd still tears the old job down. Only `print` proves the job
201
+ // is loaded, so retry until it does.
202
+ let boot: CommandResult = { ok: false, detail: '' };
203
+ let kick: CommandResult = { ok: false, detail: '' };
204
+ for (let attempt = 0; attempt < 3; attempt++) {
205
+ if (attempt > 0) pause.ms(1_000);
206
+ boot = run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist]);
207
+ kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${paths.label}`]);
208
+ if (run('launchctl', ['print', `${launchdTarget()}/${paths.label}`]).ok) {
209
+ return { installed: true, active: true, detail: joinDetails(boot, kick) };
210
+ }
211
+ }
212
+ return { installed: true, active: null, detail: joinDetails(boot, kick) };
173
213
  },
174
214
 
175
215
  uninstall(paths) {
@@ -183,7 +223,7 @@ const launchd: ServiceDriver = {
183
223
  const boot = installed
184
224
  ? run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist])
185
225
  : notInstalled('LaunchAgent');
186
- const kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${SERVICE_LABEL}`]);
226
+ const kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${paths.label}`]);
187
227
  return { active: boot.ok || kick.ok ? true : null, detail: joinDetails(boot, kick) };
188
228
  },
189
229
 
@@ -194,10 +234,29 @@ const launchd: ServiceDriver = {
194
234
  return { detail: stop.detail };
195
235
  },
196
236
 
237
+ pause(paths) {
238
+ // `disable` is stored by launchd across logins; bootstrap refuses a disabled job.
239
+ const disable = run('launchctl', ['disable', `${launchdTarget()}/${paths.label}`]);
240
+ const stop = run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]);
241
+ return { enabled: disable.ok ? false : undefined, detail: joinDetails(disable, stop) };
242
+ },
243
+
244
+ resume(paths, installed) {
245
+ const enable = run('launchctl', ['enable', `${launchdTarget()}/${paths.label}`]);
246
+ const boot = installed
247
+ ? run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist])
248
+ : notInstalled('LaunchAgent');
249
+ const kick = run('launchctl', ['kickstart', `${launchdTarget()}/${paths.label}`]);
250
+ return { enabled: enable.ok, active: boot.ok || kick.ok ? true : null, detail: joinDetails(enable, boot, kick) };
251
+ },
252
+
197
253
  status(paths, installed) {
198
- const status = run('launchctl', ['print', `${launchdTarget()}/${SERVICE_LABEL}`]);
254
+ const disabled = run('launchctl', ['print-disabled', launchdTarget()]);
255
+ const enabled = !new RegExp(`"${paths.label.replace(/\./g, '\\.')}" => (disabled|true)`).test(disabled.detail);
256
+ const status = run('launchctl', ['print', `${launchdTarget()}/${paths.label}`]);
199
257
  return {
200
258
  active: status.ok,
259
+ enabled,
201
260
  detail: status.detail || (installed ? readFileSync(paths.launchdPlist, 'utf8') : undefined),
202
261
  };
203
262
  },
@@ -205,83 +264,169 @@ const launchd: ServiceDriver = {
205
264
 
206
265
  const systemd: ServiceDriver = {
207
266
  unitPath: (paths) => paths.systemdUnit,
267
+ render: (paths, runner) => renderSystemdUnit(posixShellCommand(runner), paths),
208
268
 
209
269
  install(paths, runner) {
210
270
  mkdirSync(dirname(paths.systemdUnit), { recursive: true });
211
- writeFileSync(paths.systemdUnit, renderSystemdUnit(posixShellCommand(runner), paths), { mode: 0o600 });
271
+ writeFileSync(paths.systemdUnit, systemd.render(paths, runner), { mode: 0o600 });
212
272
  const reload = run('systemctl', ['--user', 'daemon-reload']);
213
- const enable = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_LABEL}.service`]);
214
- return { installed: true, active: enable.ok ? true : null, detail: joinDetails(reload, enable) };
273
+ const enable = run('systemctl', ['--user', 'enable', `${paths.label}.service`]);
274
+ // `enable --now` leaves an already-running agent alone, so an app update
275
+ // never rolled it (R5). `restart` starts a stopped unit too.
276
+ const restart = run('systemctl', ['--user', 'restart', `${paths.label}.service`]);
277
+ return {
278
+ installed: true,
279
+ active: restart.ok ? true : null,
280
+ detail: joinDetails(reload, enable, restart, ensureLinger()),
281
+ };
215
282
  },
216
283
 
217
284
  uninstall(paths) {
218
285
  const existed = existsSync(paths.systemdUnit);
219
- const disable = run('systemctl', ['--user', 'disable', '--now', `${SERVICE_LABEL}.service`]);
286
+ const disable = run('systemctl', ['--user', 'disable', '--now', `${paths.label}.service`]);
220
287
  if (existed) rmSync(paths.systemdUnit, { force: true });
221
288
  run('systemctl', ['--user', 'daemon-reload']);
222
289
  return { detail: disable.detail };
223
290
  },
224
291
 
225
- start(_paths, installed) {
292
+ start(paths, installed) {
226
293
  const start = installed
227
- ? run('systemctl', ['--user', 'start', `${SERVICE_LABEL}.service`])
294
+ ? run('systemctl', ['--user', 'start', `${paths.label}.service`])
228
295
  : notInstalled('systemd unit');
229
296
  return { active: start.ok ? true : null, detail: start.detail };
230
297
  },
231
298
 
232
- stop(_paths, installed) {
299
+ stop(paths, installed) {
233
300
  const stop = installed
234
- ? run('systemctl', ['--user', 'stop', `${SERVICE_LABEL}.service`])
301
+ ? run('systemctl', ['--user', 'stop', `${paths.label}.service`])
235
302
  : notInstalled('systemd unit');
236
303
  return { detail: stop.detail };
237
304
  },
238
305
 
239
- status() {
240
- const status = run('systemctl', ['--user', 'is-active', `${SERVICE_LABEL}.service`]);
241
- return { active: status.ok, detail: status.detail };
306
+ pause(paths) {
307
+ const disable = run('systemctl', ['--user', 'disable', '--now', `${paths.label}.service`]);
308
+ return { enabled: disable.ok ? false : undefined, active: false, detail: disable.detail };
309
+ },
310
+
311
+ resume(paths) {
312
+ const enable = run('systemctl', ['--user', 'enable', '--now', `${paths.label}.service`]);
313
+ return { enabled: enable.ok, active: enable.ok ? true : null, detail: enable.detail };
314
+ },
315
+
316
+ status(paths) {
317
+ const status = run('systemctl', ['--user', 'is-active', `${paths.label}.service`]);
318
+ const enabled = run('systemctl', ['--user', 'is-enabled', `${paths.label}.service`]);
319
+ return { active: status.ok, enabled: enabled.ok, detail: status.detail };
242
320
  },
243
321
  };
244
322
 
323
+ /**
324
+ * A user service stops at logout and does not start at boot unless the user
325
+ * lingers: an SSH-only server would go offline when the session ends.
326
+ */
327
+ function ensureLinger(): CommandResult {
328
+ const user = userInfo().username;
329
+ const linger = run('loginctl', ['show-user', user, '-p', 'Linger']);
330
+ if (/Linger=yes/.test(linger.detail)) return { ok: true, detail: '' };
331
+ if (run('loginctl', ['enable-linger', user]).ok) return { ok: true, detail: 'Enabled lingering: the service keeps running after logout and starts at boot.' };
332
+ return {
333
+ ok: false,
334
+ detail: `Warning: the service stops when you log out and starts only at your next login. Run \`loginctl enable-linger ${user}\` (may need sudo) to keep it running.`,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Task Scheduler definition. schtasks' own defaults stop the task on battery,
340
+ * refuse to start it on battery, and kill it after 72 h, and a visible console
341
+ * window ends the loop when the user closes it. Written as UTF-16, the
342
+ * encoding schtasks /XML expects.
343
+ */
344
+ export function renderWindowsTaskXml(paths: ServicePaths, user: string): string {
345
+ return `<?xml version="1.0" encoding="UTF-16"?>
346
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
347
+ <RegistrationInfo><Description>Kortix Agent Tunnel</Description></RegistrationInfo>
348
+ <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xmlEscape(user)}</UserId></LogonTrigger></Triggers>
349
+ <Principals><Principal id="Author"><UserId>${xmlEscape(user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
350
+ <Settings>
351
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
352
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
353
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
354
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
355
+ <StartWhenAvailable>true</StartWhenAvailable>
356
+ <Enabled>true</Enabled>
357
+ </Settings>
358
+ <Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>${xmlEscape(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${paths.windowsScript}"`)}</Arguments></Exec></Actions>
359
+ </Task>
360
+ `;
361
+ }
362
+
363
+ const windowsUser = () => (process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${userInfo().username}` : userInfo().username);
364
+ const windowsTaskXmlPath = (paths: ServicePaths) => paths.windowsScript.replace(/\.ps1$/, '.xml');
365
+
245
366
  const scheduledTask: ServiceDriver = {
246
367
  unitPath: (paths) => paths.windowsScript,
368
+ render: (_paths, runner) => renderWindowsPowerShellScript(runner),
247
369
 
248
370
  install(paths, runner) {
249
371
  writeFileSync(paths.windowsScript, renderWindowsPowerShellScript(runner), { mode: 0o600 });
250
- const create = run('schtasks.exe', [
251
- '/Create', '/TN', SERVICE_LABEL,
252
- '/TR', `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${paths.windowsScript}"`,
253
- '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
254
- ]);
255
- const start = run('schtasks.exe', ['/Run', '/TN', SERVICE_LABEL]);
372
+ const xml = windowsTaskXmlPath(paths);
373
+ writeFileSync(xml, Buffer.from(`\ufeff${renderWindowsTaskXml(paths, windowsUser())}`, 'utf16le'), { mode: 0o600 });
374
+ const create = run('schtasks.exe', ['/Create', '/TN', paths.label, '/XML', xml, '/F']);
375
+ // /Run does nothing while the old loop runs, so an app update never rolled
376
+ // the agent (R5). End it first; /End on a stopped task only reports an error.
377
+ run('schtasks.exe', ['/End', '/TN', paths.label]);
378
+ const start = run('schtasks.exe', ['/Run', '/TN', paths.label]);
256
379
  return { installed: create.ok, active: start.ok ? true : null, detail: joinDetails(create, start) };
257
380
  },
258
381
 
259
382
  uninstall(paths) {
260
383
  const existed = existsSync(paths.windowsScript);
261
- const stop = run('schtasks.exe', ['/End', '/TN', SERVICE_LABEL]);
262
- const del = run('schtasks.exe', ['/Delete', '/TN', SERVICE_LABEL, '/F']);
384
+ const stop = run('schtasks.exe', ['/End', '/TN', paths.label]);
385
+ const del = run('schtasks.exe', ['/Delete', '/TN', paths.label, '/F']);
263
386
  if (existed) rmSync(paths.windowsScript, { force: true });
387
+ rmSync(windowsTaskXmlPath(paths), { force: true });
264
388
  return { detail: joinDetails(stop, del) };
265
389
  },
266
390
 
267
- start(_paths, installed) {
391
+ start(paths, installed) {
268
392
  const start = installed
269
- ? run('schtasks.exe', ['/Run', '/TN', SERVICE_LABEL])
393
+ ? run('schtasks.exe', ['/Run', '/TN', paths.label])
270
394
  : notInstalled('Scheduled Task');
271
395
  return { active: start.ok ? true : null, detail: start.detail };
272
396
  },
273
397
 
274
- stop(_paths, installed) {
398
+ stop(paths, installed) {
275
399
  const stop = installed
276
- ? run('schtasks.exe', ['/End', '/TN', SERVICE_LABEL])
400
+ ? run('schtasks.exe', ['/End', '/TN', paths.label])
277
401
  : notInstalled('Scheduled Task');
278
402
  return { detail: stop.detail };
279
403
  },
280
404
 
405
+ pause(paths) {
406
+ const disable = run('schtasks.exe', ['/Change', '/TN', paths.label, '/DISABLE']);
407
+ const stop = run('schtasks.exe', ['/End', '/TN', paths.label]);
408
+ return { enabled: disable.ok ? false : undefined, active: false, detail: joinDetails(disable, stop) };
409
+ },
410
+
411
+ resume(paths) {
412
+ const enable = run('schtasks.exe', ['/Change', '/TN', paths.label, '/ENABLE']);
413
+ const start = run('schtasks.exe', ['/Run', '/TN', paths.label]);
414
+ return { enabled: enable.ok, active: start.ok ? true : null, detail: joinDetails(enable, start) };
415
+ },
416
+
281
417
  status(paths, installed) {
282
- const status = run('schtasks.exe', ['/Query', '/TN', SERVICE_LABEL, '/FO', 'LIST', '/V']);
283
- const detail = status.detail || (installed ? readFileSync(paths.windowsScript, 'utf8') : undefined);
284
- return { active: status.ok ? /Status:\s*Running/i.test(detail ?? '') : false, detail };
418
+ // schtasks /Query prints localized labels and values ("Wird ausgeführt");
419
+ // the ScheduledTask State enum is not localized: Ready, Running, Disabled, Queued.
420
+ const status = run('powershell.exe', [
421
+ '-NoProfile', '-NonInteractive', '-Command',
422
+ `(Get-ScheduledTask -TaskName ${powershellQuote(paths.label)} -ErrorAction Stop).State`,
423
+ ]);
424
+ const state = status.ok ? status.detail.trim() : '';
425
+ return {
426
+ active: state === 'Running',
427
+ enabled: state !== 'Disabled',
428
+ detail: state || (installed ? readFileSync(paths.windowsScript, 'utf8') : undefined),
429
+ };
285
430
  },
286
431
  };
287
432
 
@@ -294,6 +439,10 @@ const DRIVERS: Partial<Record<NodeJS.Platform, ServiceDriver>> = {
294
439
  export const SUPPORTED_PLATFORMS_MESSAGE =
295
440
  'Background services are supported on macOS launchd, Linux systemd user services, and Windows Scheduled Tasks.';
296
441
 
442
+ export function serviceDriverFor(os: NodeJS.Platform): ServiceDriver | undefined {
443
+ return DRIVERS[os];
444
+ }
445
+
297
446
  export function serviceDriver(): ServiceDriver | undefined {
298
- return DRIVERS[platform()];
447
+ return serviceDriverFor(platform());
299
448
  }
@@ -6,8 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import { join, resolve } from 'node:path';
7
7
  import {
8
8
  MAX_SERVICE_LOG_BYTES,
9
- TERMINAL_SERVICE_EXIT_CODE,
10
- getServicePaths,
9
+ getServicePaths,
11
10
  renderLaunchdPlist,
12
11
  renderSystemdUnit,
13
12
  renderWindowsPowerShellScript,
@@ -27,30 +26,30 @@ afterEach(async () => {
27
26
  temporaryHomes.clear();
28
27
  });
29
28
 
30
- describe('supervisor restart policy', () => {
31
- test('launchd restarts only on a failure exit', () => {
29
+ describe('supervisor restart policy (R3)', () => {
30
+ test('launchd restarts on every exit, throttled to 10 s', () => {
32
31
  const plist = renderLaunchdPlist('exec /bin/echo tunnel');
33
- expect(plist).toContain('<key>SuccessfulExit</key>');
34
- expect(plist).toContain('<false/>');
35
- // The old unconditional form is what produced the endless respawn loop.
36
- expect(plist).not.toContain('<key>KeepAlive</key>\n <true/>');
32
+ expect(plist).toContain('<key>KeepAlive</key>\n <true/>');
33
+ expect(plist).toContain('<key>ThrottleInterval</key>');
34
+ expect(plist).not.toContain('SuccessfulExit');
37
35
  });
38
36
 
39
- test('systemd restarts only on a failure exit', () => {
37
+ test('systemd restarts on every exit', () => {
40
38
  const unit = renderSystemdUnit('exec /bin/echo tunnel');
41
- expect(unit).toContain('Restart=on-failure');
42
- expect(unit).not.toContain('Restart=always');
39
+ expect(unit).toContain('Restart=always');
40
+ expect(unit).not.toContain('Restart=on-failure');
43
41
  });
44
42
 
45
- test('windows loop breaks on the terminal exit code', () => {
43
+ test('the windows loop never breaks', () => {
46
44
  const script = renderWindowsPowerShellScript({
47
45
  command: 'node',
48
46
  args: ['agent-tunnel.js', 'run', '--service'],
49
47
  });
50
- expect(script).toContain(`if ($LASTEXITCODE -eq ${TERMINAL_SERVICE_EXIT_CODE}) { break }`);
48
+ expect(script).not.toContain('break');
49
+ expect(script).toContain('while ($true)');
51
50
  });
52
51
 
53
- test('run --service exits terminally when no credential is saved', async () => {
52
+ test('run --service without a credential stays alive and waits for one (R2)', async () => {
54
53
  const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-nocred-'));
55
54
  temporaryHomes.add(home);
56
55
 
@@ -59,10 +58,16 @@ describe('supervisor restart policy', () => {
59
58
  stdio: ['ignore', 'pipe', 'pipe'],
60
59
  });
61
60
  children.add(child);
62
- const exitCode = await new Promise<number | null>((r) => child.once('exit', r));
61
+ let stdout = '';
62
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
63
+ const exited = await Promise.race([
64
+ new Promise<boolean>((r) => child.once('exit', () => r(true))),
65
+ Bun.sleep(2_500).then(() => false),
66
+ ]);
63
67
 
64
- // A non-zero exit here would make the supervisor respawn it forever.
65
- expect(exitCode).toBe(TERMINAL_SERVICE_EXIT_CODE);
68
+ // Exiting here would only make the always-restarting supervisor spin.
69
+ expect(exited).toBe(false);
70
+ expect(stdout).toContain('waiting for a credential');
66
71
  }, 30_000);
67
72
  });
68
73
 
@@ -241,8 +246,17 @@ describe('status output', () => {
241
246
  child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
242
247
  await new Promise((r) => child.once('exit', r));
243
248
 
244
- const status = JSON.parse(stdout) as { paired: boolean; capabilities: string[] };
249
+ const status = JSON.parse(stdout) as {
250
+ paired: boolean;
251
+ capabilities: string[];
252
+ access: { mode: string };
253
+ service: { upToDate?: boolean; enabled?: boolean };
254
+ };
245
255
  expect(status.paired).toBe(true);
246
256
  expect(status.capabilities).toEqual(['filesystem', 'shell']);
257
+ // A machine paired before access control existed stays always-allowed.
258
+ expect(status.access.mode).toBe('always');
259
+ // Not installed, so it cannot match what an install would write now.
260
+ expect(status.service.upToDate).toBe(false);
247
261
  }, 30_000);
248
262
  });
@@ -1,16 +1,45 @@
1
+ import { createHash } from 'crypto';
1
2
  import { readFileSync, writeFileSync } from 'fs';
2
3
  import { homedir } from 'os';
3
- import { join } from 'path';
4
+ import { join, resolve } from 'path';
4
5
 
6
+ /** Label of the default service. A non-default AGENT_TUNNEL_HOME gets a suffixed one. */
5
7
  export const SERVICE_LABEL = 'ai.kortix.agent-tunnel';
8
+
9
+ export function defaultAgentTunnelHome(): string {
10
+ return join(homedir(), '.agent-tunnel');
11
+ }
12
+
13
+ /**
14
+ * Config directory: `AGENT_TUNNEL_HOME`, else `~/.agent-tunnel`. The override
15
+ * lets a desktop dev build or a test pair a second identity on one machine
16
+ * without touching the real one.
17
+ */
18
+ export function agentTunnelHome(env: NodeJS.ProcessEnv = process.env): string {
19
+ const override = env.AGENT_TUNNEL_HOME?.trim();
20
+ return override ? resolve(override) : defaultAgentTunnelHome();
21
+ }
22
+
23
+ /**
24
+ * The default home keeps the historical label, so existing installs keep
25
+ * managing the service they already have. Any other home gets a stable suffix
26
+ * from its path, so its service never replaces the real one.
27
+ */
28
+ export function serviceLabelFor(home: string): string {
29
+ const dir = resolve(home);
30
+ if (dir === defaultAgentTunnelHome()) return SERVICE_LABEL;
31
+ return `${SERVICE_LABEL}.${createHash('sha256').update(dir).digest('hex').slice(0, 8)}`;
32
+ }
33
+
34
+ /** Environment the supervised process needs to find the same home again. */
35
+ export function serviceHomeEnv(home: string = agentTunnelHome()): Record<string, string> {
36
+ return resolve(home) === defaultAgentTunnelHome() ? {} : { AGENT_TUNNEL_HOME: resolve(home) };
37
+ }
6
38
  export const DEFAULT_INSTALL_BACKGROUND_SERVICE = true;
7
39
 
8
40
  /**
9
- * Exit code for conditions that restarting cannot fix: no saved credential, or
10
- * a credential the relay refuses. Every supervisor is configured to restart on
11
- * failure only, so a terminal condition ends the service instead of spinning.
12
- * Without this, a revoked token produces an endless respawn loop whose only
13
- * trace is a log file nobody reads.
41
+ * Kept for 0.1.x importers only. Since contract v2 (R2) a service never exits
42
+ * on its own and every supervisor restarts on any exit, so nothing uses it.
14
43
  */
15
44
  export const TERMINAL_SERVICE_EXIT_CODE = 0;
16
45
 
@@ -19,6 +48,8 @@ export const MAX_SERVICE_LOG_BYTES = 5 * 1024 * 1024;
19
48
  const RETAINED_LOG_LINES = 500;
20
49
 
21
50
  export interface ServicePaths {
51
+ /** launchd label, systemd unit stem, and Scheduled Task name. */
52
+ label: string;
22
53
  configDir: string;
23
54
  logDir: string;
24
55
  binDir: string;
@@ -28,17 +59,18 @@ export interface ServicePaths {
28
59
  windowsScript: string;
29
60
  }
30
61
 
31
- export function getServicePaths(): ServicePaths {
62
+ export function getServicePaths(configDir: string = agentTunnelHome()): ServicePaths {
32
63
  const home = homedir();
33
- const configDir = join(home, '.agent-tunnel');
64
+ const label = serviceLabelFor(configDir);
34
65
  const binDir = join(configDir, 'bin');
35
66
  return {
67
+ label,
36
68
  configDir,
37
69
  logDir: join(configDir, 'logs'),
38
70
  binDir,
39
71
  vendoredRunner: join(binDir, 'agent-cli.js'),
40
- launchdPlist: join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`),
41
- systemdUnit: join(home, '.config', 'systemd', 'user', `${SERVICE_LABEL}.service`),
72
+ launchdPlist: join(home, 'Library', 'LaunchAgents', `${label}.plist`),
73
+ systemdUnit: join(home, '.config', 'systemd', 'user', `${label}.service`),
42
74
  windowsScript: join(configDir, 'agent-tunnel-service.ps1'),
43
75
  };
44
76
  }