@kortix/agent-tunnel 0.14.7 → 0.14.9
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 +16 -22
- package/package.json +1 -1
- package/src/agent/access.test.ts +27 -1
- package/src/agent/access.ts +1 -10
- package/src/agent/cli-device-auth.test.ts +44 -0
- package/src/agent/device-auth.test.ts +47 -0
- package/src/agent/device-auth.ts +1 -1
- package/src/agent/process-liveness.ts +9 -0
- package/src/agent/state-file.test.ts +26 -1
- package/src/agent/state-file.ts +1 -10
package/dist/agent-cli.js
CHANGED
|
@@ -3478,6 +3478,20 @@ import { randomUUID } from "crypto";
|
|
|
3478
3478
|
import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, renameSync, rmSync, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
3479
3479
|
import { platform } from "os";
|
|
3480
3480
|
import { isAbsolute, join as join3 } from "path";
|
|
3481
|
+
|
|
3482
|
+
// src/agent/process-liveness.ts
|
|
3483
|
+
function isAlive(pid) {
|
|
3484
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
3485
|
+
return false;
|
|
3486
|
+
try {
|
|
3487
|
+
process.kill(pid, 0);
|
|
3488
|
+
return true;
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
return error.code === "EPERM";
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
// src/agent/access.ts
|
|
3481
3495
|
var ACCESS_HOLD_MS = 20000;
|
|
3482
3496
|
var DESKTOP_APP_HEARTBEAT_MS = 30000;
|
|
3483
3497
|
var MAX_GRANT_MS = 24 * 3600000;
|
|
@@ -3651,16 +3665,6 @@ function clearAccessRequest(id, home = agentTunnelHome()) {
|
|
|
3651
3665
|
if (readAccessRequest(home)?.id === id)
|
|
3652
3666
|
rmSync(accessRequestPath(home), { force: true });
|
|
3653
3667
|
}
|
|
3654
|
-
function isAlive(pid) {
|
|
3655
|
-
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
3656
|
-
return false;
|
|
3657
|
-
try {
|
|
3658
|
-
process.kill(pid, 0);
|
|
3659
|
-
return true;
|
|
3660
|
-
} catch (error) {
|
|
3661
|
-
return error.code === "EPERM";
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
3668
|
function wakeDesktopApp(home = agentTunnelHome(), now = Date.now()) {
|
|
3665
3669
|
let app;
|
|
3666
3670
|
let touchedAt = 0;
|
|
@@ -3899,7 +3903,7 @@ function openBrowser(url) {
|
|
|
3899
3903
|
};
|
|
3900
3904
|
const [command, args] = opener[platform2()] ?? ["xdg-open", [safeUrl]];
|
|
3901
3905
|
try {
|
|
3902
|
-
spawn2(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
3906
|
+
spawn2(command, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
|
|
3903
3907
|
} catch {}
|
|
3904
3908
|
}
|
|
3905
3909
|
|
|
@@ -6369,22 +6373,12 @@ function writeAgentState(status, tunnel, home = agentTunnelHome()) {
|
|
|
6369
6373
|
renameSync3(tmp, stateFilePath(home));
|
|
6370
6374
|
} catch {}
|
|
6371
6375
|
}
|
|
6372
|
-
function isAlive2(pid) {
|
|
6373
|
-
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
6374
|
-
return false;
|
|
6375
|
-
try {
|
|
6376
|
-
process.kill(pid, 0);
|
|
6377
|
-
return true;
|
|
6378
|
-
} catch (error) {
|
|
6379
|
-
return error.code === "EPERM";
|
|
6380
|
-
}
|
|
6381
|
-
}
|
|
6382
6376
|
function readAgentState(home = agentTunnelHome()) {
|
|
6383
6377
|
try {
|
|
6384
6378
|
const parsed = JSON.parse(readFileSync10(stateFilePath(home), "utf8"));
|
|
6385
6379
|
if (!parsed || typeof parsed !== "object" || typeof parsed.status !== "string")
|
|
6386
6380
|
return null;
|
|
6387
|
-
return parsed.status !== "offline" && !
|
|
6381
|
+
return parsed.status !== "offline" && !isAlive(parsed.pid) ? { ...parsed, status: "offline" } : parsed;
|
|
6388
6382
|
} catch {
|
|
6389
6383
|
return null;
|
|
6390
6384
|
}
|
package/package.json
CHANGED
package/src/agent/access.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from 'bun:test';
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -272,3 +272,29 @@ describe('access after pairing', () => {
|
|
|
272
272
|
expect(accessAfterPairing(tempHome(), false).mode).toBe('always');
|
|
273
273
|
});
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
// Characterize the PID probe through the desktop observer, without launching an app.
|
|
277
|
+
describe('desktop PID liveness', () => {
|
|
278
|
+
test.each([null, '1', 0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('rejects invalid PID %p without probing', (pid) => {
|
|
279
|
+
const home = tempHome();
|
|
280
|
+
writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ pid }));
|
|
281
|
+
const kill = spyOn(process, 'kill');
|
|
282
|
+
try {
|
|
283
|
+
expect(wakeDesktopApp(home)).toBe('no-app');
|
|
284
|
+
expect(kill).not.toHaveBeenCalled();
|
|
285
|
+
} finally { kill.mockRestore(); }
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test.each(['live', 'ESRCH', 'EPERM'])('handles %s', (result) => {
|
|
289
|
+
const home = tempHome();
|
|
290
|
+
writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ pid: process.pid }));
|
|
291
|
+
const kill = spyOn(process, 'kill').mockImplementation(() => {
|
|
292
|
+
if (result !== 'live') throw Object.assign(new Error(result), { code: result });
|
|
293
|
+
return true;
|
|
294
|
+
});
|
|
295
|
+
try {
|
|
296
|
+
expect(wakeDesktopApp(home)).toBe(result === 'ESRCH' ? 'no-app' : 'running');
|
|
297
|
+
expect(kill).toHaveBeenCalledWith(process.pid, 0);
|
|
298
|
+
} finally { kill.mockRestore(); }
|
|
299
|
+
});
|
|
300
|
+
});
|
package/src/agent/access.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sta
|
|
|
4
4
|
import { platform } from 'os';
|
|
5
5
|
import { isAbsolute, join } from 'path';
|
|
6
6
|
|
|
7
|
+
import { isAlive } from './process-liveness';
|
|
7
8
|
import { agentTunnelHome } from './service-paths';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -271,16 +272,6 @@ export function clearAccessRequest(id: string, home = agentTunnelHome()): void {
|
|
|
271
272
|
if (readAccessRequest(home)?.id === id) rmSync(accessRequestPath(home), { force: true });
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
function isAlive(pid: unknown): boolean {
|
|
275
|
-
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
276
|
-
try {
|
|
277
|
-
process.kill(pid, 0);
|
|
278
|
-
return true;
|
|
279
|
-
} catch (error) {
|
|
280
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
275
|
/**
|
|
285
276
|
* Starts the desktop app so it can show the access prompt. The app records how
|
|
286
277
|
* to launch it in `desktop-app.json` at startup and touches the file while it
|
|
@@ -31,6 +31,50 @@ async function waitForFile(path: string, timeoutMs = 5_000): Promise<void> {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
describe('agent tunnel device authorization CLI', () => {
|
|
34
|
+
test('Node pairing keeps the approval URL and polls when the browser cannot start', async () => {
|
|
35
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-node-home-'));
|
|
36
|
+
temporaryHomes.add(home);
|
|
37
|
+
const bundle = join(home, 'cli.mjs');
|
|
38
|
+
const build = await Bun.build({ entrypoints: [CLI_PATH], target: 'node' });
|
|
39
|
+
expect(build.success).toBe(true);
|
|
40
|
+
await Bun.write(bundle, await build.outputs[0].text());
|
|
41
|
+
let polls = 0;
|
|
42
|
+
const verificationUrl = 'https://example.invalid/tunnel/authorize/TEST-0004';
|
|
43
|
+
const server = Bun.serve({
|
|
44
|
+
port: 0,
|
|
45
|
+
fetch(request) {
|
|
46
|
+
if (request.method === 'POST') {
|
|
47
|
+
return Response.json({
|
|
48
|
+
deviceCode: 'TEST-0004', deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
49
|
+
verificationUrl, expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
50
|
+
pollIntervalMs: 250,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
polls++;
|
|
54
|
+
return Response.json({ status: polls === 1 ? 'pending' : 'denied' });
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
const child = Bun.spawn([
|
|
59
|
+
Bun.which('node') ?? '/usr/local/bin/node', bundle, 'connect', '--foreground',
|
|
60
|
+
'--api-url', `http://127.0.0.1:${server.port}/v1/tunnel`,
|
|
61
|
+
], {
|
|
62
|
+
env: { ...process.env, HOME: home, AGENT_TUNNEL_HOME: join(home, 'agent'),
|
|
63
|
+
PATH: home, KORTIX_AGENT_TUNNEL_NO_BROWSER: '0' },
|
|
64
|
+
stdout: 'pipe', stderr: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
67
|
+
new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
|
|
68
|
+
]);
|
|
69
|
+
expect(stdout).toContain(verificationUrl);
|
|
70
|
+
expect(stderr).toContain('Authorization denied.');
|
|
71
|
+
expect(stderr).not.toContain('Unhandled');
|
|
72
|
+
expect(polls).toBe(2);
|
|
73
|
+
expect(exitCode).toBe(1);
|
|
74
|
+
} finally {
|
|
75
|
+
server.stop(true);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
34
78
|
test('persists the exact browser-approved capability list as the local ceiling', async () => {
|
|
35
79
|
const approvedCapabilities = ['desktop', 'filesystem'];
|
|
36
80
|
const server = Bun.serve({
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import {
|
|
3
6
|
InvalidDeviceAuthResponseError,
|
|
4
7
|
awaitDeviceAuthorization,
|
|
@@ -9,6 +12,50 @@ import {
|
|
|
9
12
|
requestDeviceAuthorization,
|
|
10
13
|
} from './device-auth';
|
|
11
14
|
|
|
15
|
+
describe('browser opener on Node', () => {
|
|
16
|
+
test.each([
|
|
17
|
+
['missing opener', 'https://example.invalid/approve', '0'],
|
|
18
|
+
['invalid URL', 'not a URL', '0'],
|
|
19
|
+
['unsafe scheme', 'javascript:alert(1)', '0'],
|
|
20
|
+
['browser disabled', 'https://example.invalid/approve', '1'],
|
|
21
|
+
])('%s does not terminate authorization polling', async (_name, url, disabled) => {
|
|
22
|
+
const directory = await mkdtemp(join(tmpdir(), 'device-browser-'));
|
|
23
|
+
try {
|
|
24
|
+
const bundle = join(directory, 'device-auth.mjs');
|
|
25
|
+
const build = await Bun.build({
|
|
26
|
+
entrypoints: [join(import.meta.dir, 'device-auth.ts')],
|
|
27
|
+
target: 'node',
|
|
28
|
+
});
|
|
29
|
+
expect(build.success).toBe(true);
|
|
30
|
+
await Bun.write(bundle, await build.outputs[0].text());
|
|
31
|
+
const child = Bun.spawn([Bun.which('node') ?? '/usr/local/bin/node', '--input-type=module', '-e', `
|
|
32
|
+
import { openBrowser, awaitDeviceAuthorization } from ${JSON.stringify(bundle)};
|
|
33
|
+
openBrowser(${JSON.stringify(url)});
|
|
34
|
+
await awaitDeviceAuthorization('http://127.0.0.1:1', {
|
|
35
|
+
deviceCode: 'TEST-0001', deviceSecret: 'synthetic',
|
|
36
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(), pollIntervalMs: 250,
|
|
37
|
+
}, { onWaiting() { console.log('polling'); }, sleep: async () => {
|
|
38
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
39
|
+
console.log('survived');
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}});
|
|
42
|
+
`], {
|
|
43
|
+
env: { ...process.env, PATH: directory, KORTIX_AGENT_TUNNEL_NO_BROWSER: disabled },
|
|
44
|
+
stdout: 'pipe', stderr: 'pipe',
|
|
45
|
+
});
|
|
46
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
47
|
+
new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
|
|
48
|
+
]);
|
|
49
|
+
expect(stderr).toBe('');
|
|
50
|
+
expect(exitCode).toBe(0);
|
|
51
|
+
expect(stdout).toContain('polling');
|
|
52
|
+
expect(stdout).toContain('survived');
|
|
53
|
+
} finally {
|
|
54
|
+
await rm(directory, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
12
59
|
const VALID_TUNNEL_ID = '00000000-0000-4000-8000-000000000042';
|
|
13
60
|
const VALID_TOKEN = `kortix_tnl_${'A'.repeat(36)}`;
|
|
14
61
|
|
package/src/agent/device-auth.ts
CHANGED
|
@@ -291,7 +291,7 @@ export function openBrowser(url: string): void {
|
|
|
291
291
|
const [command, args] = opener[platform()] ?? ['xdg-open', [safeUrl]];
|
|
292
292
|
|
|
293
293
|
try {
|
|
294
|
-
spawn(command, args, { detached: true, stdio: 'ignore' }).unref();
|
|
294
|
+
spawn(command, args, { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
|
|
295
295
|
} catch {
|
|
296
296
|
// An unopenable browser is not fatal: the URL is printed too.
|
|
297
297
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function isAlive(pid: unknown): boolean {
|
|
2
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
3
|
+
try {
|
|
4
|
+
process.kill(pid, 0);
|
|
5
|
+
return true;
|
|
6
|
+
} catch (error) {
|
|
7
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
1
|
+
import { describe, expect, spyOn, test } from 'bun:test';
|
|
2
2
|
import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -40,3 +40,28 @@ describe('agent state file', () => {
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
describe('state PID liveness', () => {
|
|
45
|
+
test.each([null, '1', 0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1])('rejects invalid PID %p without probing', (pid) => {
|
|
46
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-state-'));
|
|
47
|
+
writeFileSync(stateFilePath(home), JSON.stringify({ status: 'online', pid }));
|
|
48
|
+
const kill = spyOn(process, 'kill');
|
|
49
|
+
try {
|
|
50
|
+
expect(readAgentState(home)?.status).toBe('offline');
|
|
51
|
+
expect(kill).not.toHaveBeenCalled();
|
|
52
|
+
} finally { kill.mockRestore(); rmSync(home, { recursive: true, force: true }); }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test.each(['live', 'ESRCH', 'EPERM'])('handles %s', (result) => {
|
|
56
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-state-'));
|
|
57
|
+
writeFileSync(stateFilePath(home), JSON.stringify({ status: 'online', pid: process.pid }));
|
|
58
|
+
const kill = spyOn(process, 'kill').mockImplementation(() => {
|
|
59
|
+
if (result !== 'live') throw Object.assign(new Error(result), { code: result });
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
expect(readAgentState(home)?.status).toBe(result === 'ESRCH' ? 'offline' : 'online');
|
|
64
|
+
expect(kill).toHaveBeenCalledWith(process.pid, 0);
|
|
65
|
+
} finally { kill.mockRestore(); rmSync(home, { recursive: true, force: true }); }
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/agent/state-file.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'f
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
import type { TunnelAgentStatus } from './agent';
|
|
5
|
+
import { isAlive } from './process-liveness';
|
|
5
6
|
import { agentTunnelHome } from './service-paths';
|
|
6
7
|
import { agentTunnelVersion } from './version';
|
|
7
8
|
|
|
@@ -48,16 +49,6 @@ export function writeAgentState(
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function isAlive(pid: unknown): boolean {
|
|
52
|
-
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
53
|
-
try {
|
|
54
|
-
process.kill(pid, 0);
|
|
55
|
-
return true;
|
|
56
|
-
} catch (error) {
|
|
57
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
52
|
/** The last status the agent wrote. A writer that died without writing `offline` reads as offline. */
|
|
62
53
|
export function readAgentState(home: string = agentTunnelHome()): AgentStateFile | null {
|
|
63
54
|
try {
|