@kortix/agent-tunnel 0.13.41 → 0.13.42
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.
- package/README.md +120 -11
- package/dist/agent-cli.js +1144 -435
- package/dist/client-cli.js +1 -3
- package/package.json +1 -1
- package/src/agent/access.test.ts +164 -0
- package/src/agent/access.ts +176 -0
- package/src/agent/agent.test.ts +641 -0
- package/src/agent/agent.ts +333 -10
- package/src/agent/capabilities/security.test.ts +20 -1
- package/src/agent/cli-device-auth.test.ts +114 -0
- package/src/agent/cli-reauth.test.ts +108 -8
- package/src/agent/cli.ts +201 -57
- package/src/agent/config.test.ts +5 -0
- package/src/agent/config.ts +17 -2
- package/src/agent/credential-probe.ts +3 -3
- package/src/agent/credential-store.ts +2 -2
- package/src/agent/device-auth.test.ts +57 -0
- package/src/agent/device-auth.ts +45 -3
- package/src/agent/security/path-validator.ts +3 -1
- package/src/agent/service-control.ts +5 -2
- package/src/agent/service-drivers.test.ts +164 -0
- package/src/agent/service-drivers.ts +205 -56
- package/src/agent/service-lifecycle.test.ts +32 -18
- package/src/agent/service-paths.ts +42 -10
- package/src/agent/service.test.ts +126 -5
- package/src/agent/service.ts +99 -12
- package/src/agent/state-file.test.ts +32 -0
- package/src/agent/state-file.ts +58 -0
- package/src/client/tunnel-client.ts +6 -3
- package/src/server/ws-handler.test.ts +31 -1
- package/src/server/ws-handler.ts +3 -3
- package/src/shared/types.ts +12 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
86
|
-
//
|
|
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(
|
|
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
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
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=
|
|
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:
|
|
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
|
-
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
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,
|
|
196
|
+
writeFileSync(paths.launchdPlist, launchd.render(paths, runner), { mode: 0o600 });
|
|
169
197
|
run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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()}/${
|
|
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
|
|
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,
|
|
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',
|
|
214
|
-
|
|
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', `${
|
|
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(
|
|
292
|
+
start(paths, installed) {
|
|
226
293
|
const start = installed
|
|
227
|
-
? run('systemctl', ['--user', 'start', `${
|
|
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(
|
|
299
|
+
stop(paths, installed) {
|
|
233
300
|
const stop = installed
|
|
234
|
-
? run('systemctl', ['--user', 'stop', `${
|
|
301
|
+
? run('systemctl', ['--user', 'stop', `${paths.label}.service`])
|
|
235
302
|
: notInstalled('systemd unit');
|
|
236
303
|
return { detail: stop.detail };
|
|
237
304
|
},
|
|
238
305
|
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
return {
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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',
|
|
262
|
-
const del = run('schtasks.exe', ['/Delete', '/TN',
|
|
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(
|
|
391
|
+
start(paths, installed) {
|
|
268
392
|
const start = installed
|
|
269
|
-
? run('schtasks.exe', ['/Run', '/TN',
|
|
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(
|
|
398
|
+
stop(paths, installed) {
|
|
275
399
|
const stop = installed
|
|
276
|
-
? run('schtasks.exe', ['/End', '/TN',
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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>
|
|
34
|
-
expect(plist).toContain('<
|
|
35
|
-
|
|
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
|
|
37
|
+
test('systemd restarts on every exit', () => {
|
|
40
38
|
const unit = renderSystemdUnit('exec /bin/echo tunnel');
|
|
41
|
-
expect(unit).toContain('Restart=
|
|
42
|
-
expect(unit).not.toContain('Restart=
|
|
39
|
+
expect(unit).toContain('Restart=always');
|
|
40
|
+
expect(unit).not.toContain('Restart=on-failure');
|
|
43
41
|
});
|
|
44
42
|
|
|
45
|
-
test('windows loop breaks
|
|
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(
|
|
48
|
+
expect(script).not.toContain('break');
|
|
49
|
+
expect(script).toContain('while ($true)');
|
|
51
50
|
});
|
|
52
51
|
|
|
53
|
-
test('run --service
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
-
expect(
|
|
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 {
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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', `${
|
|
41
|
-
systemdUnit: join(home, '.config', 'systemd', 'user', `${
|
|
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
|
}
|