@kortix/agent-tunnel 0.14.8 → 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 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" && !isAlive2(parsed.pid) ? { ...parsed, status: "offline" } : parsed;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.14.8",
3
+ "version": "0.14.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kortix-ai/suna.git",
@@ -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
+ });
@@ -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
 
@@ -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
+ });
@@ -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 {