@kortix/agent-tunnel 0.12.8 → 0.13.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.
- package/dist/agent-cli.js +1191 -887
- package/package.json +1 -1
- package/src/agent/agent.ts +53 -29
- package/src/agent/banner.ts +82 -0
- package/src/agent/cli-help.test.ts +79 -15
- package/src/agent/cli-reauth.test.ts +205 -0
- package/src/agent/cli.ts +399 -661
- package/src/agent/config.ts +19 -0
- package/src/agent/credential-probe.ts +95 -0
- package/src/agent/credential-store.ts +74 -0
- package/src/agent/device-auth.test.ts +132 -0
- package/src/agent/device-auth.ts +213 -0
- package/src/agent/log-format.ts +24 -0
- package/src/agent/prompts.ts +37 -0
- package/src/agent/service-control.ts +69 -0
- package/src/agent/service-drivers.ts +299 -0
- package/src/agent/service-lifecycle.test.ts +248 -0
- package/src/agent/service-paths.ts +80 -0
- package/src/agent/service-quoting.ts +16 -0
- package/src/agent/service.test.ts +52 -2
- package/src/agent/service.ts +132 -356
- package/src/agent/terminal.ts +53 -0
- package/src/agent/version.ts +52 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { homedir, platform, userInfo } from 'os';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { powershellQuote, shellQuote, xmlEscape } from './service-quoting';
|
|
6
|
+
import type { ServicePaths } from './service-paths';
|
|
7
|
+
import { SERVICE_LABEL, TERMINAL_SERVICE_EXIT_CODE, getServicePaths } from './service-paths';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One driver per supervisor.
|
|
11
|
+
*
|
|
12
|
+
* These six operations used to be six exported functions, each with the same
|
|
13
|
+
* three-way `platform()` chain and its own "not supported" throw — fifteen
|
|
14
|
+
* branches expressing one dispatch. The table below is that dispatch, so adding
|
|
15
|
+
* or fixing a platform touches one object instead of six functions.
|
|
16
|
+
*/
|
|
17
|
+
export interface ServiceDriver {
|
|
18
|
+
/** File that proves the service is installed, and is shown to the user. */
|
|
19
|
+
unitPath(paths: ServicePaths): string;
|
|
20
|
+
install(paths: ServicePaths, runner: RunnerParts): Outcome;
|
|
21
|
+
uninstall(paths: ServicePaths): Outcome;
|
|
22
|
+
start(paths: ServicePaths, installed: boolean): Outcome;
|
|
23
|
+
stop(paths: ServicePaths, installed: boolean): Outcome;
|
|
24
|
+
status(paths: ServicePaths, installed: boolean): Outcome;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The interpreter plus arguments the supervisor must launch. */
|
|
28
|
+
export interface RunnerParts {
|
|
29
|
+
command: string;
|
|
30
|
+
args: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolves the interpreter at start rather than baking one absolute path in.
|
|
35
|
+
* A version-managed Node (nvm, fnm, volta) moves when the user upgrades, which
|
|
36
|
+
* would otherwise strand the service.
|
|
37
|
+
*/
|
|
38
|
+
export function posixShellCommand(runner: RunnerParts): string {
|
|
39
|
+
const interpreter = `"$(command -v ${shellQuote(runner.command)} 2>/dev/null || command -v node)"`;
|
|
40
|
+
return `exec ${interpreter} ${runner.args.map(shellQuote).join(' ')}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Outcome {
|
|
44
|
+
/** `null` means "requested, but the supervisor did not confirm". */
|
|
45
|
+
active?: boolean | null;
|
|
46
|
+
installed?: boolean;
|
|
47
|
+
detail?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CommandResult {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
detail: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
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
|
+
}
|
|
62
|
+
|
|
63
|
+
const notInstalled = (what: string): CommandResult => ({ ok: false, detail: `${what} is not installed.` });
|
|
64
|
+
|
|
65
|
+
function joinDetails(...results: CommandResult[]): string {
|
|
66
|
+
return results.map((result) => result.detail).filter(Boolean).join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function launchdTarget(): string {
|
|
70
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : userInfo().uid;
|
|
71
|
+
return `gui/${uid}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── templates ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function logPaths(paths: ServicePaths): { stdout: string; stderr: string } {
|
|
77
|
+
return {
|
|
78
|
+
stdout: join(paths.logDir, 'agent-tunnel.out.log'),
|
|
79
|
+
stderr: join(paths.logDir, 'agent-tunnel.err.log'),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderLaunchdPlist(command: string, paths: ServicePaths = getServicePaths()): string {
|
|
84
|
+
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.
|
|
87
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
88
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
89
|
+
<plist version="1.0">
|
|
90
|
+
<dict>
|
|
91
|
+
<key>Label</key>
|
|
92
|
+
<string>${xmlEscape(SERVICE_LABEL)}</string>
|
|
93
|
+
<key>ProgramArguments</key>
|
|
94
|
+
<array>
|
|
95
|
+
<string>/bin/sh</string>
|
|
96
|
+
<string>-lc</string>
|
|
97
|
+
<string>${xmlEscape(command)}</string>
|
|
98
|
+
</array>
|
|
99
|
+
<key>RunAtLoad</key>
|
|
100
|
+
<true/>
|
|
101
|
+
<key>KeepAlive</key>
|
|
102
|
+
<dict>
|
|
103
|
+
<key>SuccessfulExit</key>
|
|
104
|
+
<false/>
|
|
105
|
+
</dict>
|
|
106
|
+
<key>Umask</key>
|
|
107
|
+
<integer>63</integer>
|
|
108
|
+
<key>StandardOutPath</key>
|
|
109
|
+
<string>${xmlEscape(stdout)}</string>
|
|
110
|
+
<key>StandardErrorPath</key>
|
|
111
|
+
<string>${xmlEscape(stderr)}</string>
|
|
112
|
+
<key>WorkingDirectory</key>
|
|
113
|
+
<string>${xmlEscape(homedir())}</string>
|
|
114
|
+
<key>EnvironmentVariables</key>
|
|
115
|
+
<dict>
|
|
116
|
+
<key>PATH</key>
|
|
117
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
118
|
+
</dict>
|
|
119
|
+
</dict>
|
|
120
|
+
</plist>
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function renderSystemdUnit(command: string, paths: ServicePaths = getServicePaths()): string {
|
|
125
|
+
const { stdout, stderr } = logPaths(paths);
|
|
126
|
+
return `[Unit]
|
|
127
|
+
Description=Kortix Agent Tunnel
|
|
128
|
+
After=network-online.target
|
|
129
|
+
Wants=network-online.target
|
|
130
|
+
|
|
131
|
+
[Service]
|
|
132
|
+
Type=simple
|
|
133
|
+
UMask=0077
|
|
134
|
+
ExecStart=/bin/sh -lc ${shellQuote(command)}
|
|
135
|
+
Restart=on-failure
|
|
136
|
+
RestartSec=5
|
|
137
|
+
WorkingDirectory=${homedir()}
|
|
138
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
139
|
+
StandardOutput=append:${stdout}
|
|
140
|
+
StandardError=append:${stderr}
|
|
141
|
+
|
|
142
|
+
[Install]
|
|
143
|
+
WantedBy=default.target
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function renderWindowsPowerShellScript(runner: { command: string; args: string[] }): string {
|
|
148
|
+
const command = powershellQuote(runner.command);
|
|
149
|
+
const args = runner.args.map(powershellQuote).join(' ');
|
|
150
|
+
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 }
|
|
156
|
+
Start-Sleep -Seconds 5
|
|
157
|
+
}
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── drivers ──────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const launchd: ServiceDriver = {
|
|
164
|
+
unitPath: (paths) => paths.launchdPlist,
|
|
165
|
+
|
|
166
|
+
install(paths, runner) {
|
|
167
|
+
mkdirSync(dirname(paths.launchdPlist), { recursive: true });
|
|
168
|
+
writeFileSync(paths.launchdPlist, renderLaunchdPlist(posixShellCommand(runner), paths), { mode: 0o600 });
|
|
169
|
+
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) };
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
uninstall(paths) {
|
|
176
|
+
const existed = existsSync(paths.launchdPlist);
|
|
177
|
+
const stop = run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]);
|
|
178
|
+
if (existed) rmSync(paths.launchdPlist, { force: true });
|
|
179
|
+
return { detail: stop.detail };
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
start(paths, installed) {
|
|
183
|
+
const boot = installed
|
|
184
|
+
? run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist])
|
|
185
|
+
: notInstalled('LaunchAgent');
|
|
186
|
+
const kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${SERVICE_LABEL}`]);
|
|
187
|
+
return { active: boot.ok || kick.ok ? true : null, detail: joinDetails(boot, kick) };
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
stop(paths, installed) {
|
|
191
|
+
const stop = installed
|
|
192
|
+
? run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist])
|
|
193
|
+
: notInstalled('LaunchAgent');
|
|
194
|
+
return { detail: stop.detail };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
status(paths, installed) {
|
|
198
|
+
const status = run('launchctl', ['print', `${launchdTarget()}/${SERVICE_LABEL}`]);
|
|
199
|
+
return {
|
|
200
|
+
active: status.ok,
|
|
201
|
+
detail: status.detail || (installed ? readFileSync(paths.launchdPlist, 'utf8') : undefined),
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const systemd: ServiceDriver = {
|
|
207
|
+
unitPath: (paths) => paths.systemdUnit,
|
|
208
|
+
|
|
209
|
+
install(paths, runner) {
|
|
210
|
+
mkdirSync(dirname(paths.systemdUnit), { recursive: true });
|
|
211
|
+
writeFileSync(paths.systemdUnit, renderSystemdUnit(posixShellCommand(runner), paths), { mode: 0o600 });
|
|
212
|
+
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) };
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
uninstall(paths) {
|
|
218
|
+
const existed = existsSync(paths.systemdUnit);
|
|
219
|
+
const disable = run('systemctl', ['--user', 'disable', '--now', `${SERVICE_LABEL}.service`]);
|
|
220
|
+
if (existed) rmSync(paths.systemdUnit, { force: true });
|
|
221
|
+
run('systemctl', ['--user', 'daemon-reload']);
|
|
222
|
+
return { detail: disable.detail };
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
start(_paths, installed) {
|
|
226
|
+
const start = installed
|
|
227
|
+
? run('systemctl', ['--user', 'start', `${SERVICE_LABEL}.service`])
|
|
228
|
+
: notInstalled('systemd unit');
|
|
229
|
+
return { active: start.ok ? true : null, detail: start.detail };
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
stop(_paths, installed) {
|
|
233
|
+
const stop = installed
|
|
234
|
+
? run('systemctl', ['--user', 'stop', `${SERVICE_LABEL}.service`])
|
|
235
|
+
: notInstalled('systemd unit');
|
|
236
|
+
return { detail: stop.detail };
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
status() {
|
|
240
|
+
const status = run('systemctl', ['--user', 'is-active', `${SERVICE_LABEL}.service`]);
|
|
241
|
+
return { active: status.ok, detail: status.detail };
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const scheduledTask: ServiceDriver = {
|
|
246
|
+
unitPath: (paths) => paths.windowsScript,
|
|
247
|
+
|
|
248
|
+
install(paths, runner) {
|
|
249
|
+
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]);
|
|
256
|
+
return { installed: create.ok, active: start.ok ? true : null, detail: joinDetails(create, start) };
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
uninstall(paths) {
|
|
260
|
+
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']);
|
|
263
|
+
if (existed) rmSync(paths.windowsScript, { force: true });
|
|
264
|
+
return { detail: joinDetails(stop, del) };
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
start(_paths, installed) {
|
|
268
|
+
const start = installed
|
|
269
|
+
? run('schtasks.exe', ['/Run', '/TN', SERVICE_LABEL])
|
|
270
|
+
: notInstalled('Scheduled Task');
|
|
271
|
+
return { active: start.ok ? true : null, detail: start.detail };
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
stop(_paths, installed) {
|
|
275
|
+
const stop = installed
|
|
276
|
+
? run('schtasks.exe', ['/End', '/TN', SERVICE_LABEL])
|
|
277
|
+
: notInstalled('Scheduled Task');
|
|
278
|
+
return { detail: stop.detail };
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
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 };
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const DRIVERS: Partial<Record<NodeJS.Platform, ServiceDriver>> = {
|
|
289
|
+
darwin: launchd,
|
|
290
|
+
linux: systemd,
|
|
291
|
+
win32: scheduledTask,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export const SUPPORTED_PLATFORMS_MESSAGE =
|
|
295
|
+
'Background services are supported on macOS launchd, Linux systemd user services, and Windows Scheduled Tasks.';
|
|
296
|
+
|
|
297
|
+
export function serviceDriver(): ServiceDriver | undefined {
|
|
298
|
+
return DRIVERS[platform()];
|
|
299
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
MAX_SERVICE_LOG_BYTES,
|
|
9
|
+
TERMINAL_SERVICE_EXIT_CODE,
|
|
10
|
+
getServicePaths,
|
|
11
|
+
renderLaunchdPlist,
|
|
12
|
+
renderSystemdUnit,
|
|
13
|
+
renderWindowsPowerShellScript,
|
|
14
|
+
rotateServiceLogs,
|
|
15
|
+
} from './service';
|
|
16
|
+
import { collapseRepeatedLines } from './log-format';
|
|
17
|
+
import { probeCredentials } from './credential-probe';
|
|
18
|
+
|
|
19
|
+
const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
|
|
20
|
+
const children = new Set<ChildProcess>();
|
|
21
|
+
const temporaryHomes = new Set<string>();
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
for (const child of children) child.kill('SIGTERM');
|
|
25
|
+
children.clear();
|
|
26
|
+
await Promise.all([...temporaryHomes].map((p) => rm(p, { recursive: true, force: true })));
|
|
27
|
+
temporaryHomes.clear();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('supervisor restart policy', () => {
|
|
31
|
+
test('launchd restarts only on a failure exit', () => {
|
|
32
|
+
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/>');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('systemd restarts only on a failure exit', () => {
|
|
40
|
+
const unit = renderSystemdUnit('exec /bin/echo tunnel');
|
|
41
|
+
expect(unit).toContain('Restart=on-failure');
|
|
42
|
+
expect(unit).not.toContain('Restart=always');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('windows loop breaks on the terminal exit code', () => {
|
|
46
|
+
const script = renderWindowsPowerShellScript({
|
|
47
|
+
command: 'node',
|
|
48
|
+
args: ['agent-tunnel.js', 'run', '--service'],
|
|
49
|
+
});
|
|
50
|
+
expect(script).toContain(`if ($LASTEXITCODE -eq ${TERMINAL_SERVICE_EXIT_CODE}) { break }`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('run --service exits terminally when no credential is saved', async () => {
|
|
54
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-nocred-'));
|
|
55
|
+
temporaryHomes.add(home);
|
|
56
|
+
|
|
57
|
+
const child = spawn(process.execPath, ['run', CLI_PATH, 'run', '--service'], {
|
|
58
|
+
env: { ...process.env, HOME: home },
|
|
59
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
+
});
|
|
61
|
+
children.add(child);
|
|
62
|
+
const exitCode = await new Promise<number | null>((r) => child.once('exit', r));
|
|
63
|
+
|
|
64
|
+
// A non-zero exit here would make the supervisor respawn it forever.
|
|
65
|
+
expect(exitCode).toBe(TERMINAL_SERVICE_EXIT_CODE);
|
|
66
|
+
}, 30_000);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('service log hygiene', () => {
|
|
70
|
+
test('collapses repeated lines and keeps the count', () => {
|
|
71
|
+
expect(collapseRepeatedLines(['a', 'a', 'a', 'b', 'a'])).toEqual(['a (x3)', 'b', 'a']);
|
|
72
|
+
expect(collapseRepeatedLines([])).toEqual([]);
|
|
73
|
+
expect(collapseRepeatedLines(['only'])).toEqual(['only']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('trims a log that grew past the cap', () => {
|
|
77
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-rotate-'));
|
|
78
|
+
try {
|
|
79
|
+
const logDir = join(home, 'logs');
|
|
80
|
+
mkdirSync(logDir, { recursive: true });
|
|
81
|
+
const paths = { ...getServicePaths(), logDir };
|
|
82
|
+
const outLog = join(logDir, 'agent-tunnel.out.log');
|
|
83
|
+
|
|
84
|
+
const oversized = `${'noise line\n'.repeat(60_000)}final line\n`;
|
|
85
|
+
writeFileSync(outLog, oversized);
|
|
86
|
+
expect(oversized.length).toBeGreaterThan(MAX_SERVICE_LOG_BYTES / 10);
|
|
87
|
+
|
|
88
|
+
const rotated = rotateServiceLogs(paths, 1024);
|
|
89
|
+
expect(rotated).toContain(outLog);
|
|
90
|
+
|
|
91
|
+
const body = readFileSync(outLog, 'utf8');
|
|
92
|
+
expect(body).toStartWith('[agent-tunnel] earlier entries trimmed');
|
|
93
|
+
expect(body).toContain('final line');
|
|
94
|
+
expect(body.length).toBeLessThan(64 * 1024);
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(home, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('credential probe and the single-agent rule', () => {
|
|
102
|
+
test('treats being replaced as proof the credential is valid', async () => {
|
|
103
|
+
// The relay only replaces a socket it already registered, and registration
|
|
104
|
+
// happens after a successful handshake.
|
|
105
|
+
const server = Bun.serve({
|
|
106
|
+
port: 0,
|
|
107
|
+
fetch(request, server) {
|
|
108
|
+
return server.upgrade(request) ? undefined : new Response('no upgrade', { status: 400 });
|
|
109
|
+
},
|
|
110
|
+
websocket: {
|
|
111
|
+
message(ws) {
|
|
112
|
+
ws.close(4004, 'replaced by another agent process');
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const result = await probeCredentials(
|
|
119
|
+
{
|
|
120
|
+
apiUrl: `http://127.0.0.1:${server.port}/v1/tunnel`,
|
|
121
|
+
tunnelId: '00000000-0000-4000-8000-000000000001',
|
|
122
|
+
token: 'kortix_tnl_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
123
|
+
wsPath: '/ws',
|
|
124
|
+
} as never,
|
|
125
|
+
{ timeoutMs: 5_000 },
|
|
126
|
+
);
|
|
127
|
+
expect(result).toBe('valid');
|
|
128
|
+
} finally {
|
|
129
|
+
server.stop(true);
|
|
130
|
+
}
|
|
131
|
+
}, 30_000);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('capability approval', () => {
|
|
135
|
+
test('refuses to save a pairing that approved nothing', async () => {
|
|
136
|
+
const server = Bun.serve({
|
|
137
|
+
port: 0,
|
|
138
|
+
fetch(request) {
|
|
139
|
+
const url = new URL(request.url);
|
|
140
|
+
if (request.method === 'POST' && url.pathname === '/v1/tunnel/device-auth') {
|
|
141
|
+
return Response.json(
|
|
142
|
+
{
|
|
143
|
+
deviceCode: 'ZERO-0001',
|
|
144
|
+
deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
145
|
+
verificationUrl: 'https://dev.kortix.com/tunnel/authorize/ZERO-0001',
|
|
146
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
147
|
+
pollIntervalMs: 250,
|
|
148
|
+
},
|
|
149
|
+
{ status: 201 },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (request.method === 'GET' && url.pathname.endsWith('/ZERO-0001/status')) {
|
|
153
|
+
return Response.json({
|
|
154
|
+
status: 'approved',
|
|
155
|
+
tunnelId: '00000000-0000-4000-8000-000000000042',
|
|
156
|
+
token: 'kortix_tnl_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
|
157
|
+
capabilities: [],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return new Response('not found', { status: 404 });
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-zerocap-'));
|
|
165
|
+
temporaryHomes.add(home);
|
|
166
|
+
await mkdir(join(home, '.agent-tunnel'), { recursive: true, mode: 0o700 });
|
|
167
|
+
|
|
168
|
+
const child = spawn(
|
|
169
|
+
process.execPath,
|
|
170
|
+
[
|
|
171
|
+
'run',
|
|
172
|
+
CLI_PATH,
|
|
173
|
+
'connect',
|
|
174
|
+
'--foreground',
|
|
175
|
+
'--api-url',
|
|
176
|
+
`http://127.0.0.1:${server.port}/v1/tunnel`,
|
|
177
|
+
],
|
|
178
|
+
{
|
|
179
|
+
env: { ...process.env, HOME: home, KORTIX_AGENT_TUNNEL_NO_BROWSER: '1' },
|
|
180
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
children.add(child);
|
|
184
|
+
let stdout = '';
|
|
185
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const exitCode = await new Promise<number | null>((r) => child.once('exit', r));
|
|
189
|
+
expect(exitCode).toBe(1);
|
|
190
|
+
expect(stdout).toContain('No capabilities were approved');
|
|
191
|
+
// An empty ceiling can only be widened by pairing again, so it must
|
|
192
|
+
// never reach disk in the first place.
|
|
193
|
+
await expect(readFile(join(home, '.agent-tunnel', 'config.json'), 'utf8')).rejects.toThrow();
|
|
194
|
+
} finally {
|
|
195
|
+
child.kill('SIGTERM');
|
|
196
|
+
server.stop(true);
|
|
197
|
+
}
|
|
198
|
+
}, 30_000);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('status output', () => {
|
|
202
|
+
test('reports an unpaired machine without inventing a connection', async () => {
|
|
203
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-status-'));
|
|
204
|
+
temporaryHomes.add(home);
|
|
205
|
+
|
|
206
|
+
const child = spawn(process.execPath, ['run', CLI_PATH, 'status', '--json'], {
|
|
207
|
+
env: { ...process.env, HOME: home },
|
|
208
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
209
|
+
});
|
|
210
|
+
children.add(child);
|
|
211
|
+
let stdout = '';
|
|
212
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
|
213
|
+
await new Promise((r) => child.once('exit', r));
|
|
214
|
+
|
|
215
|
+
const status = JSON.parse(stdout) as { paired: boolean; tunnelId: string | null };
|
|
216
|
+
expect(status.paired).toBe(false);
|
|
217
|
+
expect(status.tunnelId).toBeNull();
|
|
218
|
+
}, 30_000);
|
|
219
|
+
|
|
220
|
+
test('reports the approved capability ceiling for a paired machine', async () => {
|
|
221
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-status-paired-'));
|
|
222
|
+
temporaryHomes.add(home);
|
|
223
|
+
await mkdir(join(home, '.agent-tunnel'), { recursive: true, mode: 0o700 });
|
|
224
|
+
await writeFile(
|
|
225
|
+
join(home, '.agent-tunnel', 'config.json'),
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
tunnelId: '00000000-0000-4000-8000-000000000042',
|
|
228
|
+
token: 'kortix_tnl_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
|
229
|
+
apiUrl: 'https://api.kortix.com/v1/tunnel',
|
|
230
|
+
enabledCapabilities: ['filesystem', 'shell'],
|
|
231
|
+
}),
|
|
232
|
+
{ mode: 0o600 },
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const child = spawn(process.execPath, ['run', CLI_PATH, 'status', '--json'], {
|
|
236
|
+
env: { ...process.env, HOME: home },
|
|
237
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
238
|
+
});
|
|
239
|
+
children.add(child);
|
|
240
|
+
let stdout = '';
|
|
241
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
|
242
|
+
await new Promise((r) => child.once('exit', r));
|
|
243
|
+
|
|
244
|
+
const status = JSON.parse(stdout) as { paired: boolean; capabilities: string[] };
|
|
245
|
+
expect(status.paired).toBe(true);
|
|
246
|
+
expect(status.capabilities).toEqual(['filesystem', 'shell']);
|
|
247
|
+
}, 30_000);
|
|
248
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
export const SERVICE_LABEL = 'ai.kortix.agent-tunnel';
|
|
6
|
+
export const DEFAULT_INSTALL_BACKGROUND_SERVICE = true;
|
|
7
|
+
|
|
8
|
+
/**
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
export const TERMINAL_SERVICE_EXIT_CODE = 0;
|
|
16
|
+
|
|
17
|
+
/** Supervised logs are appended to forever; launchd and systemd never rotate. */
|
|
18
|
+
export const MAX_SERVICE_LOG_BYTES = 5 * 1024 * 1024;
|
|
19
|
+
const RETAINED_LOG_LINES = 500;
|
|
20
|
+
|
|
21
|
+
export interface ServicePaths {
|
|
22
|
+
configDir: string;
|
|
23
|
+
logDir: string;
|
|
24
|
+
binDir: string;
|
|
25
|
+
vendoredRunner: string;
|
|
26
|
+
launchdPlist: string;
|
|
27
|
+
systemdUnit: string;
|
|
28
|
+
windowsScript: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getServicePaths(): ServicePaths {
|
|
32
|
+
const home = homedir();
|
|
33
|
+
const configDir = join(home, '.agent-tunnel');
|
|
34
|
+
const binDir = join(configDir, 'bin');
|
|
35
|
+
return {
|
|
36
|
+
configDir,
|
|
37
|
+
logDir: join(configDir, 'logs'),
|
|
38
|
+
binDir,
|
|
39
|
+
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`),
|
|
42
|
+
windowsScript: join(configDir, 'agent-tunnel-service.ps1'),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function serviceLogFiles(paths: ServicePaths = getServicePaths()): string[] {
|
|
47
|
+
return [
|
|
48
|
+
join(paths.logDir, 'agent-tunnel.out.log'),
|
|
49
|
+
join(paths.logDir, 'agent-tunnel.err.log'),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Trims oversized log files in place, keeping the most recent lines.
|
|
55
|
+
*
|
|
56
|
+
* A restart loop can produce megabytes of identical lines — 23 MB was observed
|
|
57
|
+
* on a real machine. Supervisors hold these files open in append mode, so
|
|
58
|
+
* rewriting the contents is safe while the service runs.
|
|
59
|
+
*/
|
|
60
|
+
export function rotateServiceLogs(
|
|
61
|
+
paths: ServicePaths = getServicePaths(),
|
|
62
|
+
maxBytes = MAX_SERVICE_LOG_BYTES,
|
|
63
|
+
): string[] {
|
|
64
|
+
const rotated: string[] = [];
|
|
65
|
+
for (const file of serviceLogFiles(paths)) {
|
|
66
|
+
try {
|
|
67
|
+
// Read once and decide from the bytes in hand. Checking existence or size
|
|
68
|
+
// first and then reading is a race: the supervisor appends continuously,
|
|
69
|
+
// so the file examined need not be the file read.
|
|
70
|
+
const contents = readFileSync(file, 'utf8');
|
|
71
|
+
if (Buffer.byteLength(contents, 'utf8') <= maxBytes) continue;
|
|
72
|
+
const kept = contents.split(/\r?\n/).slice(-RETAINED_LOG_LINES).join('\n');
|
|
73
|
+
writeFileSync(file, `[agent-tunnel] earlier entries trimmed\n${kept}`, { mode: 0o600 });
|
|
74
|
+
rotated.push(file);
|
|
75
|
+
} catch {
|
|
76
|
+
// A missing or unreadable log must never stop the service from starting.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return rotated;
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function shellQuote(value: string): string {
|
|
2
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function powershellQuote(value: string): string {
|
|
6
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function xmlEscape(value: string): string {
|
|
10
|
+
return value
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|