@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
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
+
import { posixShellCommand } from './service-drivers';
|
|
4
5
|
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
5
7
|
import {
|
|
6
8
|
DEFAULT_INSTALL_BACKGROUND_SERVICE,
|
|
7
9
|
SERVICE_LABEL,
|
|
10
|
+
agentTunnelHome,
|
|
8
11
|
buildServiceShellCommand,
|
|
9
12
|
getServicePaths,
|
|
13
|
+
runnerPartsFor,
|
|
14
|
+
serviceLabelFor,
|
|
10
15
|
isEphemeralRunnerPath,
|
|
11
16
|
renderLaunchdPlist,
|
|
12
17
|
renderSystemdUnit,
|
|
@@ -30,18 +35,22 @@ describe('agent tunnel service definitions', () => {
|
|
|
30
35
|
const plist = renderLaunchdPlist('exec /bin/echo tunnel');
|
|
31
36
|
expect(plist).toContain(`<string>${SERVICE_LABEL}</string>`);
|
|
32
37
|
expect(plist).toContain('<key>RunAtLoad</key>');
|
|
33
|
-
|
|
38
|
+
// R3: the service runs forever. launchd restarts it on ANY exit, at most every 10 s.
|
|
39
|
+
expect(plist).toContain('<key>KeepAlive</key>\n <true/>');
|
|
40
|
+
expect(plist).toContain('<key>ThrottleInterval</key>\n <integer>10</integer>');
|
|
41
|
+
expect(plist).not.toContain('SuccessfulExit');
|
|
34
42
|
expect(plist).toContain('<key>Umask</key>');
|
|
35
43
|
expect(plist).toContain('agent-tunnel.out.log');
|
|
36
44
|
expect(plist).toContain('agent-tunnel.err.log');
|
|
37
45
|
});
|
|
38
46
|
|
|
39
|
-
test('systemd unit restarts on
|
|
47
|
+
test('systemd unit restarts on any exit (R3)', () => {
|
|
40
48
|
const unit = renderSystemdUnit('exec /bin/echo tunnel');
|
|
41
49
|
expect(unit).toContain('Description=Kortix Agent Tunnel');
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
expect(unit).toContain('Restart=
|
|
50
|
+
// The agent itself never exits for a bad credential any more (it waits in
|
|
51
|
+
// `rejected`), so any exit is a crash worth restarting.
|
|
52
|
+
expect(unit).toContain('Restart=always');
|
|
53
|
+
expect(unit).toContain('RestartSec=5');
|
|
45
54
|
expect(unit).toContain('UMask=0077');
|
|
46
55
|
expect(unit).toContain('WantedBy=default.target');
|
|
47
56
|
expect(unit).toContain('agent-tunnel.out.log');
|
|
@@ -57,6 +66,7 @@ describe('agent tunnel service definitions', () => {
|
|
|
57
66
|
expect(script).toContain('while ($true)');
|
|
58
67
|
expect(script).toContain("& 'node' 'agent-tunnel.js' 'run' '--service'");
|
|
59
68
|
expect(script).toContain('Start-Sleep -Seconds 5');
|
|
69
|
+
expect(script).not.toContain('break');
|
|
60
70
|
});
|
|
61
71
|
|
|
62
72
|
test('treats package-manager caches as ephemeral runner locations', () => {
|
|
@@ -92,6 +102,8 @@ describe('agent tunnel service definitions', () => {
|
|
|
92
102
|
const copied = vendorRunner(cached, paths);
|
|
93
103
|
expect(copied).toBe(paths.vendoredRunner);
|
|
94
104
|
expect(readFileSync(copied, 'utf8')).toBe('// cached bundle\n');
|
|
105
|
+
// The copy reports the same version as the CLI that installed it (R5).
|
|
106
|
+
expect(JSON.parse(readFileSync(join(home, 'bin', 'package.json'), 'utf8'))).toMatchObject({ name: '@kortix/agent-tunnel' });
|
|
95
107
|
} finally {
|
|
96
108
|
rmSync(home, { recursive: true, force: true });
|
|
97
109
|
}
|
|
@@ -111,3 +123,112 @@ describe('agent tunnel service definitions', () => {
|
|
|
111
123
|
expect(paths.windowsScript).toContain('agent-tunnel-service.ps1');
|
|
112
124
|
});
|
|
113
125
|
});
|
|
126
|
+
|
|
127
|
+
describe('AGENT_TUNNEL_HOME isolation', () => {
|
|
128
|
+
const defaultHome = join(homedir(), '.agent-tunnel');
|
|
129
|
+
|
|
130
|
+
test('defaults to ~/.agent-tunnel and honours the override', () => {
|
|
131
|
+
expect(agentTunnelHome({})).toBe(defaultHome);
|
|
132
|
+
expect(agentTunnelHome({ AGENT_TUNNEL_HOME: ' ' })).toBe(defaultHome);
|
|
133
|
+
expect(agentTunnelHome({ AGENT_TUNNEL_HOME: '/tmp/kortix-dev/agent-tunnel' })).toBe(
|
|
134
|
+
'/tmp/kortix-dev/agent-tunnel',
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the default home keeps the historical service label', () => {
|
|
139
|
+
expect(serviceLabelFor(defaultHome)).toBe(SERVICE_LABEL);
|
|
140
|
+
expect(serviceLabelFor(`${defaultHome}/`)).toBe(SERVICE_LABEL);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('any other home gets a stable 8-hex suffix derived from its path', () => {
|
|
144
|
+
const label = serviceLabelFor('/tmp/kortix-dev/agent-tunnel');
|
|
145
|
+
expect(label).toMatch(/^ai\.kortix\.agent-tunnel\.[0-9a-f]{8}$/);
|
|
146
|
+
expect(serviceLabelFor('/tmp/kortix-dev/agent-tunnel')).toBe(label);
|
|
147
|
+
expect(serviceLabelFor('/tmp/kortix-dev/other')).not.toBe(label);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('service files of a non-default home never touch the real service', () => {
|
|
151
|
+
const paths = getServicePaths('/tmp/kortix-dev/agent-tunnel');
|
|
152
|
+
expect(paths.label).not.toBe(SERVICE_LABEL);
|
|
153
|
+
expect(paths.configDir).toBe('/tmp/kortix-dev/agent-tunnel');
|
|
154
|
+
expect(paths.logDir).toBe('/tmp/kortix-dev/agent-tunnel/logs');
|
|
155
|
+
expect(paths.launchdPlist).toEndWith(`/LaunchAgents/${paths.label}.plist`);
|
|
156
|
+
expect(paths.systemdUnit).toEndWith(`/${paths.label}.service`);
|
|
157
|
+
expect(paths.windowsScript).toStartWith('/tmp/kortix-dev/agent-tunnel');
|
|
158
|
+
expect(renderLaunchdPlist('exec true', paths)).toContain(`<string>${paths.label}</string>`);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('service runner', () => {
|
|
163
|
+
const devPaths = getServicePaths('/tmp/kortix-dev/agent-tunnel');
|
|
164
|
+
|
|
165
|
+
test('a Node runner carries a non-default home into the service env', () => {
|
|
166
|
+
const runner = runnerPartsFor('/opt/kortix/agent-cli.js', { execPath: '/usr/local/bin/node' }, devPaths);
|
|
167
|
+
expect(runner).toEqual({
|
|
168
|
+
command: '/usr/local/bin/node',
|
|
169
|
+
args: ['/opt/kortix/agent-cli.js', 'run', '--service'],
|
|
170
|
+
env: { AGENT_TUNNEL_HOME: '/tmp/kortix-dev/agent-tunnel' },
|
|
171
|
+
});
|
|
172
|
+
expect(runnerPartsFor('/opt/kortix/agent-cli.js', { execPath: 'node' }, getServicePaths()).env).toEqual({});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('an Electron runner sets ELECTRON_RUN_AS_NODE and is never vendored', () => {
|
|
176
|
+
// An npx-looking path would be vendored under Node; the app bundle never is.
|
|
177
|
+
const script = '/Applications/Kortix.app/Contents/Resources/agent-tunnel/agent-cli.js';
|
|
178
|
+
const runner = runnerPartsFor(
|
|
179
|
+
script,
|
|
180
|
+
{ execPath: '/Applications/Kortix.app/Contents/MacOS/Kortix', electron: '39.8.1' },
|
|
181
|
+
devPaths,
|
|
182
|
+
);
|
|
183
|
+
expect(runner.command).toBe('/Applications/Kortix.app/Contents/MacOS/Kortix');
|
|
184
|
+
expect(runner.args).toEqual([script, 'run', '--service']);
|
|
185
|
+
expect(runner.env).toEqual({
|
|
186
|
+
AGENT_TUNNEL_HOME: '/tmp/kortix-dev/agent-tunnel',
|
|
187
|
+
ELECTRON_RUN_AS_NODE: '1',
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('a Linux AppImage runs the AppImage file with a vendored bundle', () => {
|
|
192
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-appimage-'));
|
|
193
|
+
try {
|
|
194
|
+
const mount = join(home, '.mount_KortixAbc', 'resources', 'agent-tunnel');
|
|
195
|
+
mkdirSync(mount, { recursive: true });
|
|
196
|
+
writeFileSync(join(mount, 'agent-cli.js'), '// bundle\n');
|
|
197
|
+
const paths = getServicePaths(join(home, 'agent-home'));
|
|
198
|
+
const runner = runnerPartsFor(
|
|
199
|
+
join(mount, 'agent-cli.js'),
|
|
200
|
+
{ execPath: join(home, '.mount_KortixAbc', 'kortix'), electron: '39.8.1', appImage: '/home/u/Kortix.AppImage' },
|
|
201
|
+
paths,
|
|
202
|
+
);
|
|
203
|
+
expect(runner.command).toBe('/home/u/Kortix.AppImage');
|
|
204
|
+
expect(runner.args[0]).toBe(paths.vendoredRunner);
|
|
205
|
+
expect(readFileSync(paths.vendoredRunner, 'utf8')).toBe('// bundle\n');
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(home, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('launchd and systemd commands export the runner env before exec', () => {
|
|
212
|
+
const command = posixShellCommand({
|
|
213
|
+
command: '/Applications/Kortix.app/Contents/MacOS/Kortix',
|
|
214
|
+
args: ['agent-cli.js', 'run', '--service'],
|
|
215
|
+
env: { AGENT_TUNNEL_HOME: "/tmp/it's here", ELECTRON_RUN_AS_NODE: '1' },
|
|
216
|
+
});
|
|
217
|
+
expect(command).toStartWith(`export AGENT_TUNNEL_HOME='/tmp/it'\\''s here' ELECTRON_RUN_AS_NODE='1'; exec `);
|
|
218
|
+
expect(renderLaunchdPlist(command, devPaths)).toContain('ELECTRON_RUN_AS_NODE='1'');
|
|
219
|
+
expect(renderSystemdUnit(command, devPaths)).toContain('ELECTRON_RUN_AS_NODE=');
|
|
220
|
+
// No env, no export: the default service definition is unchanged.
|
|
221
|
+
expect(posixShellCommand({ command: 'node', args: ['a.js'] })).toStartWith('exec ');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('the Windows script sets the env and waits for a GUI-subsystem binary', () => {
|
|
225
|
+
const script = renderWindowsPowerShellScript({
|
|
226
|
+
command: 'C:\\Kortix\\Kortix.exe',
|
|
227
|
+
args: ['agent-cli.js', 'run', '--service'],
|
|
228
|
+
env: { AGENT_TUNNEL_HOME: 'C:\\dev\\agent-tunnel', ELECTRON_RUN_AS_NODE: '1' },
|
|
229
|
+
});
|
|
230
|
+
expect(script).toContain("$env:AGENT_TUNNEL_HOME = 'C:\\dev\\agent-tunnel'");
|
|
231
|
+
expect(script).toContain("$env:ELECTRON_RUN_AS_NODE = '1'");
|
|
232
|
+
expect(script).toContain("& 'C:\\Kortix\\Kortix.exe' 'agent-cli.js' 'run' '--service' | Out-Null");
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/agent/service.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'fs';
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs';
|
|
2
2
|
import { platform } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
SUPPORTED_PLATFORMS_MESSAGE,
|
|
7
|
+
type Outcome,
|
|
7
8
|
type RunnerParts,
|
|
8
9
|
posixShellCommand,
|
|
9
10
|
serviceDriver,
|
|
@@ -12,7 +13,9 @@ import {
|
|
|
12
13
|
type ServicePaths,
|
|
13
14
|
getServicePaths,
|
|
14
15
|
rotateServiceLogs,
|
|
16
|
+
serviceHomeEnv,
|
|
15
17
|
} from './service-paths';
|
|
18
|
+
import { agentTunnelVersion } from './version';
|
|
16
19
|
|
|
17
20
|
export {
|
|
18
21
|
DEFAULT_INSTALL_BACKGROUND_SERVICE,
|
|
@@ -20,7 +23,10 @@ export {
|
|
|
20
23
|
SERVICE_LABEL,
|
|
21
24
|
TERMINAL_SERVICE_EXIT_CODE,
|
|
22
25
|
type ServicePaths,
|
|
26
|
+
agentTunnelHome,
|
|
23
27
|
getServicePaths,
|
|
28
|
+
serviceHomeEnv,
|
|
29
|
+
serviceLabelFor,
|
|
24
30
|
rotateServiceLogs,
|
|
25
31
|
serviceLogFiles,
|
|
26
32
|
} from './service-paths';
|
|
@@ -34,6 +40,13 @@ export interface ServiceStatus {
|
|
|
34
40
|
platform: NodeJS.Platform;
|
|
35
41
|
installed: boolean;
|
|
36
42
|
active: boolean | null;
|
|
43
|
+
/** False while paused: disabled in the supervisor, so login does not start it. */
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The installed unit is exactly what `install` would write now. False after
|
|
47
|
+
* an app update or move: the unit points at an old runner (R5).
|
|
48
|
+
*/
|
|
49
|
+
upToDate?: boolean;
|
|
37
50
|
path?: string;
|
|
38
51
|
detail?: string;
|
|
39
52
|
}
|
|
@@ -52,7 +65,9 @@ export function isEphemeralRunnerPath(path: string): boolean {
|
|
|
52
65
|
normalized.includes('/_npx/') ||
|
|
53
66
|
normalized.includes('/_cacache/') ||
|
|
54
67
|
normalized.includes('/.pnpm-store/') ||
|
|
55
|
-
normalized.includes('/.yarn/$$virtual/')
|
|
68
|
+
normalized.includes('/.yarn/$$virtual/') ||
|
|
69
|
+
// A Linux AppImage is mounted at a new /tmp/.mount_* path on every launch.
|
|
70
|
+
normalized.includes('/.mount_')
|
|
56
71
|
);
|
|
57
72
|
}
|
|
58
73
|
|
|
@@ -68,6 +83,13 @@ export function vendorRunner(scriptPath: string, paths: ServicePaths = getServic
|
|
|
68
83
|
const source = realpathSync(scriptPath);
|
|
69
84
|
copyFileSync(source, paths.vendoredRunner);
|
|
70
85
|
try { chmodSync(paths.vendoredRunner, 0o700); } catch {}
|
|
86
|
+
// The copy has no package.json beside it, so it would report version
|
|
87
|
+
// "unknown" and the desktop's version check (R5) could never pass.
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(paths.binDir, 'package.json'),
|
|
90
|
+
JSON.stringify({ name: '@kortix/agent-tunnel', version: agentTunnelVersion(), private: true }),
|
|
91
|
+
{ mode: 0o600 },
|
|
92
|
+
);
|
|
71
93
|
writeFileSync(
|
|
72
94
|
join(paths.binDir, 'agent-cli.source.json'),
|
|
73
95
|
JSON.stringify({ source, vendoredFrom: scriptPath }, null, 2),
|
|
@@ -76,11 +98,45 @@ export function vendorRunner(scriptPath: string, paths: ServicePaths = getServic
|
|
|
76
98
|
return paths.vendoredRunner;
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
/**
|
|
102
|
+
* The command the supervisor runs: this interpreter and this bundle.
|
|
103
|
+
*
|
|
104
|
+
* Under Electron (the desktop app runs the bundle with ELECTRON_RUN_AS_NODE)
|
|
105
|
+
* the interpreter is the app binary, which must be told to act as Node again,
|
|
106
|
+
* and the bundle sits inside the app, which the updater replaces in place, so
|
|
107
|
+
* it is not vendored. A Linux AppImage is the exception: its mount path changes
|
|
108
|
+
* per launch, so the service runs the AppImage file and a vendored bundle.
|
|
109
|
+
*/
|
|
110
|
+
export function runnerPartsFor(
|
|
111
|
+
script: string,
|
|
112
|
+
runtime: { execPath: string; electron?: string; appImage?: string } = {
|
|
113
|
+
execPath: process.execPath,
|
|
114
|
+
electron: process.versions.electron,
|
|
115
|
+
appImage: process.env.APPIMAGE,
|
|
116
|
+
},
|
|
117
|
+
paths: ServicePaths = getServicePaths(),
|
|
118
|
+
vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
|
|
119
|
+
): RunnerParts {
|
|
120
|
+
const env = serviceHomeEnv(paths.configDir);
|
|
121
|
+
if (runtime.electron) {
|
|
122
|
+
return {
|
|
123
|
+
command: runtime.appImage || runtime.execPath,
|
|
124
|
+
args: [runtime.appImage ? vendor(script, paths) : script, 'run', '--service'],
|
|
125
|
+
env: { ...env, ELECTRON_RUN_AS_NODE: '1' },
|
|
126
|
+
};
|
|
83
127
|
}
|
|
128
|
+
return { command: runtime.execPath, args: [vendor(script, paths), 'run', '--service'], env };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Where vendorRunner WOULD put the bundle, without copying anything. */
|
|
132
|
+
const plannedRunner = (script: string, paths: ServicePaths) =>
|
|
133
|
+
isEphemeralRunnerPath(script) ? paths.vendoredRunner : script;
|
|
134
|
+
|
|
135
|
+
function currentRunnerParts(
|
|
136
|
+
vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
|
|
137
|
+
): RunnerParts {
|
|
138
|
+
const script = process.argv[1];
|
|
139
|
+
if (script && existsSync(script)) return runnerPartsFor(script, undefined, getServicePaths(), vendor);
|
|
84
140
|
throw new Error(
|
|
85
141
|
'Cannot install the background service because the current Agent Tunnel executable was not found',
|
|
86
142
|
);
|
|
@@ -98,11 +154,7 @@ export function buildServiceShellCommand(): string {
|
|
|
98
154
|
* functions.
|
|
99
155
|
*/
|
|
100
156
|
function withDriver(
|
|
101
|
-
operate: (driver: NonNullable<ReturnType<typeof serviceDriver>>, paths: ServicePaths, installed: boolean) =>
|
|
102
|
-
active?: boolean | null;
|
|
103
|
-
installed?: boolean;
|
|
104
|
-
detail?: string;
|
|
105
|
-
},
|
|
157
|
+
operate: (driver: NonNullable<ReturnType<typeof serviceDriver>>, paths: ServicePaths, installed: boolean) => Outcome,
|
|
106
158
|
fallback: { installed: boolean; active: boolean | null },
|
|
107
159
|
): ServiceStatus {
|
|
108
160
|
const driver = serviceDriver();
|
|
@@ -121,6 +173,8 @@ function withDriver(
|
|
|
121
173
|
platform: platform(),
|
|
122
174
|
installed: outcome.installed ?? installed,
|
|
123
175
|
active: outcome.active ?? fallback.active,
|
|
176
|
+
...(outcome.enabled !== undefined ? { enabled: outcome.enabled } : {}),
|
|
177
|
+
...(outcome.upToDate !== undefined ? { upToDate: outcome.upToDate } : {}),
|
|
124
178
|
path,
|
|
125
179
|
detail: outcome.detail,
|
|
126
180
|
};
|
|
@@ -163,6 +217,33 @@ export function stopService(): ServiceStatus {
|
|
|
163
217
|
);
|
|
164
218
|
}
|
|
165
219
|
|
|
220
|
+
/** R3: stops the service and keeps it stopped across login and reboot. */
|
|
221
|
+
export function pauseService(): ServiceStatus {
|
|
222
|
+
return withDriver(
|
|
223
|
+
(driver, paths, installed) => ({ ...driver.pause(paths, installed), active: false }),
|
|
224
|
+
{ installed: false, active: false },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Reverses pauseService. A unit that points at an old runner (the app moved or
|
|
230
|
+
* updated while paused) is rewritten first, so Resume never starts a unit whose
|
|
231
|
+
* executable is gone.
|
|
232
|
+
*/
|
|
233
|
+
export function resumeService(): ServiceStatus {
|
|
234
|
+
const current = getServiceStatus();
|
|
235
|
+
// Install enables the job on every supervisor, so it also ends the pause.
|
|
236
|
+
if (current.installed && current.upToDate === false) return installService();
|
|
237
|
+
return resumeUnit();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function resumeUnit(): ServiceStatus {
|
|
241
|
+
return withDriver(
|
|
242
|
+
(driver, paths, installed) => driver.resume(paths, installed),
|
|
243
|
+
{ installed: false, active: null },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
166
247
|
export function restartService(): ServiceStatus {
|
|
167
248
|
stopService();
|
|
168
249
|
return startService();
|
|
@@ -180,7 +261,13 @@ export function getServiceStatus(): ServiceStatus {
|
|
|
180
261
|
};
|
|
181
262
|
}
|
|
182
263
|
return withDriver(
|
|
183
|
-
(driver, paths, installed) =>
|
|
264
|
+
(driver, paths, installed) => {
|
|
265
|
+
let upToDate = false;
|
|
266
|
+
try {
|
|
267
|
+
upToDate = installed && readFileSync(driver.unitPath(paths), 'utf8') === driver.render(paths, currentRunnerParts(plannedRunner));
|
|
268
|
+
} catch {}
|
|
269
|
+
return { ...driver.status(paths, installed), upToDate };
|
|
270
|
+
},
|
|
184
271
|
{ installed: false, active: null },
|
|
185
272
|
);
|
|
186
273
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { readAgentState, stateFilePath, writeAgentState } from './state-file';
|
|
7
|
+
|
|
8
|
+
describe('agent state file', () => {
|
|
9
|
+
test('writes the status atomically, privately, and reads it back', () => {
|
|
10
|
+
const home = join(mkdtempSync(join(tmpdir(), 'agent-tunnel-state-')), 'home');
|
|
11
|
+
try {
|
|
12
|
+
expect(readAgentState(home)).toBeNull();
|
|
13
|
+
const tunnel = { tunnelId: '00000000-0000-4000-8000-000000000001', apiUrl: 'http://127.0.0.1:8008/v1/tunnel' };
|
|
14
|
+
|
|
15
|
+
writeAgentState('connecting', tunnel, home);
|
|
16
|
+
writeAgentState('online', tunnel, home);
|
|
17
|
+
|
|
18
|
+
const state = readAgentState(home);
|
|
19
|
+
expect(state).toMatchObject({ ...tunnel, status: 'online', pid: process.pid });
|
|
20
|
+
expect(Date.parse(state!.since)).toBeGreaterThan(Date.now() - 5_000);
|
|
21
|
+
expect(typeof state!.agentVersion).toBe('string');
|
|
22
|
+
expect(statSync(stateFilePath(home)).mode & 0o077).toBe(0);
|
|
23
|
+
expect(statSync(home).mode & 0o077).toBe(0);
|
|
24
|
+
// No temp file is left behind.
|
|
25
|
+
expect(readdirSync(home)).toEqual(['state.json']);
|
|
26
|
+
// The state never carries the credential.
|
|
27
|
+
expect(JSON.stringify(state)).not.toContain('token');
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(join(home, '..'), { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
import type { TunnelAgentStatus } from './agent';
|
|
5
|
+
import { agentTunnelHome } from './service-paths';
|
|
6
|
+
import { agentTunnelVersion } from './version';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `<home>/state.json`: the running agent's connection status, for local
|
|
10
|
+
* observers such as the desktop tray. It holds no credential.
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentStateFile {
|
|
13
|
+
tunnelId: string;
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
status: TunnelAgentStatus;
|
|
16
|
+
/** ISO time of the last status change. */
|
|
17
|
+
since: string;
|
|
18
|
+
agentVersion: string;
|
|
19
|
+
pid: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stateFilePath(home: string = agentTunnelHome()): string {
|
|
23
|
+
return join(home, 'state.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Atomic and private. A status write never stops the agent. */
|
|
27
|
+
export function writeAgentState(
|
|
28
|
+
status: TunnelAgentStatus,
|
|
29
|
+
tunnel: { tunnelId: string; apiUrl: string },
|
|
30
|
+
home: string = agentTunnelHome(),
|
|
31
|
+
): void {
|
|
32
|
+
const state: AgentStateFile = {
|
|
33
|
+
tunnelId: tunnel.tunnelId,
|
|
34
|
+
apiUrl: tunnel.apiUrl,
|
|
35
|
+
status,
|
|
36
|
+
since: new Date().toISOString(),
|
|
37
|
+
agentVersion: agentTunnelVersion(),
|
|
38
|
+
pid: process.pid,
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
42
|
+
const tmp = join(home, `state.${process.pid}.${Date.now()}.tmp`);
|
|
43
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
44
|
+
try { chmodSync(tmp, 0o600); } catch {}
|
|
45
|
+
renameSync(tmp, stateFilePath(home));
|
|
46
|
+
} catch {
|
|
47
|
+
// Observers fall back to "unknown"; the tunnel itself is unaffected.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readAgentState(home: string = agentTunnelHome()): AgentStateFile | null {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(stateFilePath(home), 'utf8')) as AgentStateFile;
|
|
54
|
+
return parsed && typeof parsed === 'object' && typeof parsed.status === 'string' ? parsed : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -91,6 +91,10 @@ export class TunnelClient {
|
|
|
91
91
|
return data.result;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Kept for published 0.1.x callers. The current API never returns a
|
|
96
|
+
* `requestId` (permission requests were removed), so this equals `rpc`.
|
|
97
|
+
*/
|
|
94
98
|
async rpcWithPermissionFlow(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
95
99
|
try {
|
|
96
100
|
return await this.rpc(method, params);
|
|
@@ -147,9 +151,8 @@ export class TunnelClient {
|
|
|
147
151
|
this.cachedTunnelId = null;
|
|
148
152
|
throw new TunnelClientError(
|
|
149
153
|
-1,
|
|
150
|
-
'No tunnel connection found.
|
|
151
|
-
'
|
|
152
|
-
'2. Connect the local machine from the Kortix desktop app or run the tunnel connect command',
|
|
154
|
+
'No tunnel connection found. Connect this computer from the Kortix desktop app, ' +
|
|
155
|
+
'or run `npx @kortix/agent-tunnel connect`.',
|
|
153
156
|
);
|
|
154
157
|
}
|
|
155
158
|
}
|
|
@@ -140,7 +140,7 @@ describe('tunnel WebSocket identity binding', () => {
|
|
|
140
140
|
|
|
141
141
|
expect(relay.isConnected('tunnel-1')).toBe(false);
|
|
142
142
|
expect(socket.closes).toContainEqual({
|
|
143
|
-
code:
|
|
143
|
+
code: 1011,
|
|
144
144
|
reason: 'authentication response failed',
|
|
145
145
|
});
|
|
146
146
|
});
|
|
@@ -155,4 +155,34 @@ describe('tunnel WebSocket identity binding', () => {
|
|
|
155
155
|
|
|
156
156
|
expect(socket.closes).toContainEqual({ code: 4002, reason: 'message too large' });
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
// A relay that cannot finish auth (database down during a deploy, overload)
|
|
160
|
+
// must not tell the agent its credential is bad: agents stop for good on 4001.
|
|
161
|
+
test('closes with a retryable code when the authenticator throws', async () => {
|
|
162
|
+
const relay = new TunnelRelay();
|
|
163
|
+
const handlers = createWsHandlers(relay, {
|
|
164
|
+
onAuthenticate: async () => {
|
|
165
|
+
throw new Error('database unavailable');
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const socket = fakeWs();
|
|
169
|
+
handlers.onOpen('tunnel-1', socket);
|
|
170
|
+
const originalError = console.error;
|
|
171
|
+
console.error = () => {};
|
|
172
|
+
try {
|
|
173
|
+
await handlers.onMessage('tunnel-1', socket, JSON.stringify({ type: 'auth', token: 't' }));
|
|
174
|
+
} finally {
|
|
175
|
+
console.error = originalError;
|
|
176
|
+
}
|
|
177
|
+
expect(socket.closes).toEqual([{ code: 1011, reason: 'authentication error' }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('closes with a retryable code when auth does not complete in time', async () => {
|
|
181
|
+
const relay = new TunnelRelay();
|
|
182
|
+
const handlers = createWsHandlers(relay, { authTimeoutMs: 5 });
|
|
183
|
+
const socket = fakeWs();
|
|
184
|
+
handlers.onOpen('tunnel-1', socket);
|
|
185
|
+
await Bun.sleep(20);
|
|
186
|
+
expect(socket.closes).toEqual([{ code: 1013, reason: 'auth timeout' }]);
|
|
187
|
+
});
|
|
158
188
|
});
|
package/src/server/ws-handler.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
|
|
|
39
39
|
const timer = setTimeout(() => {
|
|
40
40
|
pendingConnections.delete(ws);
|
|
41
41
|
try {
|
|
42
|
-
ws.close(
|
|
42
|
+
ws.close(1013, 'auth timeout');
|
|
43
43
|
} catch {}
|
|
44
44
|
}, authTimeoutMs);
|
|
45
45
|
|
|
@@ -135,7 +135,7 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
|
|
|
135
135
|
);
|
|
136
136
|
} catch {
|
|
137
137
|
try {
|
|
138
|
-
ws.close(
|
|
138
|
+
ws.close(1011, 'authentication response failed');
|
|
139
139
|
} catch {}
|
|
140
140
|
return;
|
|
141
141
|
}
|
|
@@ -149,7 +149,7 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
|
|
|
149
149
|
pendingConnections.delete(ws);
|
|
150
150
|
console.error(`[tunnel-ws] Auth error for ${tunnelId}:`, err);
|
|
151
151
|
try {
|
|
152
|
-
ws.close(
|
|
152
|
+
ws.close(1011, 'authentication error');
|
|
153
153
|
} catch {}
|
|
154
154
|
}
|
|
155
155
|
|
package/src/shared/types.ts
CHANGED
|
@@ -42,6 +42,12 @@ export const TunnelErrorCode = {
|
|
|
42
42
|
EXPIRED: -32005,
|
|
43
43
|
RATE_LIMITED: -32006,
|
|
44
44
|
AUTH_FAILED: -32007,
|
|
45
|
+
/** The owner has not answered the access prompt on the machine (contract v2 X1). */
|
|
46
|
+
ACCESS_PENDING: -32010,
|
|
47
|
+
/** The owner denied access on the machine; fails fast until the denial lapses. */
|
|
48
|
+
ACCESS_DENIED: -32011,
|
|
49
|
+
/** The owner turned computer access off on the machine. */
|
|
50
|
+
ACCESS_OFF: -32012,
|
|
45
51
|
} as const;
|
|
46
52
|
|
|
47
53
|
export type TunnelErrorCodeValue = (typeof TunnelErrorCode)[keyof typeof TunnelErrorCode];
|
|
@@ -183,6 +189,12 @@ export interface TunnelAuthMessage {
|
|
|
183
189
|
/** RPC capability handlers registered in this exact agent process. */
|
|
184
190
|
capabilities?: TunnelCapability[];
|
|
185
191
|
agentVersion?: string;
|
|
192
|
+
/**
|
|
193
|
+
* True from agents that report their access mode (`tunnel.access.state`).
|
|
194
|
+
* Lets the API drop a mode stored by an earlier agent when an older one
|
|
195
|
+
* that enforces nothing connects with the same credential.
|
|
196
|
+
*/
|
|
197
|
+
reportsAccess?: boolean;
|
|
186
198
|
}
|
|
187
199
|
|
|
188
200
|
/** Result returned by onAuthenticate hook on success. */
|