@kortix/agent-tunnel 0.1.4 → 0.12.7

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.
Files changed (67) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-cli.js +4676 -3174
  3. package/dist/client-cli.js +202 -493
  4. package/package.json +24 -29
  5. package/src/agent/agent.ts +67 -17
  6. package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
  7. package/src/agent/capabilities/desktop.ts +100 -167
  8. package/src/agent/capabilities/enabled-registry.ts +24 -0
  9. package/src/agent/capabilities/filesystem.ts +136 -32
  10. package/src/agent/capabilities/index.ts +1 -1
  11. package/src/agent/capabilities/security.test.ts +204 -0
  12. package/src/agent/capabilities/shell.ts +37 -3
  13. package/src/agent/cli-device-auth.test.ts +179 -0
  14. package/src/agent/cli-help.test.ts +25 -0
  15. package/src/agent/cli.ts +475 -38
  16. package/src/agent/config.test.ts +53 -0
  17. package/src/agent/config.ts +169 -7
  18. package/src/agent/index.ts +1 -0
  19. package/src/agent/security/command-validator.ts +4 -2
  20. package/src/agent/security/path-validator.ts +73 -18
  21. package/src/agent/security/permission-guard.test.ts +52 -0
  22. package/src/agent/security/permission-guard.ts +35 -8
  23. package/src/agent/service.test.ts +63 -0
  24. package/src/agent/service.ts +410 -0
  25. package/src/client/cli.test.ts +150 -547
  26. package/src/client/cli.ts +116 -539
  27. package/src/client/index.ts +1 -1
  28. package/src/client/tools.ts +95 -356
  29. package/src/client/tunnel-client.ts +50 -80
  30. package/src/index.ts +7 -1
  31. package/src/node-ws-polyfill.test.ts +18 -0
  32. package/src/node-ws-polyfill.ts +5 -3
  33. package/src/server/heartbeat.ts +13 -6
  34. package/src/server/relay.test.ts +72 -0
  35. package/src/server/relay.ts +50 -9
  36. package/src/server/server.test.ts +33 -0
  37. package/src/server/server.ts +26 -6
  38. package/src/server/ws-handler.test.ts +158 -0
  39. package/src/server/ws-handler.ts +94 -36
  40. package/src/shared/crypto.ts +2 -3
  41. package/src/shared/index.ts +8 -0
  42. package/src/shared/permissions.ts +292 -0
  43. package/src/shared/types.ts +70 -41
  44. package/dist/agent/index.d.ts +0 -140
  45. package/dist/agent/index.js +0 -21
  46. package/dist/agent/index.js.map +0 -1
  47. package/dist/chunk-7N7GSU6K.js +0 -34
  48. package/dist/client/index.d.ts +0 -183
  49. package/dist/client/index.js +0 -8
  50. package/dist/client/index.js.map +0 -1
  51. package/dist/index.d.ts +0 -7
  52. package/dist/index.js +0 -55
  53. package/dist/index.js.map +0 -1
  54. package/dist/server/index.d.ts +0 -89
  55. package/dist/server/index.js +0 -14
  56. package/dist/server/index.js.map +0 -1
  57. package/dist/shared/index.d.ts +0 -10
  58. package/dist/shared/index.js +0 -20
  59. package/dist/shared/index.js.map +0 -1
  60. package/dist/types-Dpwrd8Ai.d.ts +0 -194
  61. package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
  62. package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
  63. package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
  64. package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
  65. package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
  66. package/src/agent/capabilities/desktop/types.ts +0 -201
  67. package/src/agent/capabilities/desktop/windows-driver.ts +0 -220
@@ -0,0 +1,204 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { TunnelConfig } from '../config';
6
+ import { createDesktopCapability } from './desktop';
7
+ import { createEnabledCapabilityRegistry } from './enabled-registry';
8
+ import { CuaDriver } from './desktop/cua-driver';
9
+ import { createFilesystemCapability } from './filesystem';
10
+ import { createShellCapability } from './shell';
11
+
12
+ let root = '';
13
+ let outside = '';
14
+
15
+ function config(): TunnelConfig {
16
+ return {
17
+ token: 'kortix_tnl_test',
18
+ tunnelId: '00000000-0000-4000-8000-000000000000',
19
+ apiUrl: 'http://127.0.0.1:8008/v1/tunnel',
20
+ wsPath: '/ws',
21
+ maxFileSize: 1024,
22
+ allowedPaths: [root],
23
+ allowedCommands: [],
24
+ blockedCommands: [],
25
+ blockedPaths: [],
26
+ workingDir: root,
27
+ shellTimeout: 1_000,
28
+ shellMaxTimeout: 2_000,
29
+ shellMaxOutputSize: 1024,
30
+ shellEnvPassthrough: ['PATH'],
31
+ };
32
+ }
33
+
34
+ beforeEach(async () => {
35
+ root = await mkdtemp(join(tmpdir(), 'agent-tunnel-allowed-'));
36
+ outside = await mkdtemp(join(tmpdir(), 'agent-tunnel-outside-'));
37
+ await writeFile(join(root, 'allowed.txt'), 'allowed');
38
+ await writeFile(join(outside, 'secret.txt'), 'secret');
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await Promise.all([
43
+ rm(root, { recursive: true, force: true }),
44
+ rm(outside, { recursive: true, force: true }),
45
+ ]);
46
+ });
47
+
48
+ describe('local capability permission enforcement', () => {
49
+ test('a zero-capability approval registers no local RPC handlers', () => {
50
+ const registry = createEnabledCapabilityRegistry({
51
+ ...config(),
52
+ enabledCapabilities: [],
53
+ });
54
+ expect(registry.getCapabilityNames()).toEqual([]);
55
+ expect(registry.getHandler('fs.read')).toBeNull();
56
+ expect(registry.getHandler('shell.exec')).toBeNull();
57
+ expect(registry.getHandler('desktop.cua.call')).toBeNull();
58
+ });
59
+
60
+ test('desktop is not advertised when no trusted local driver exists', () => {
61
+ const registry = createEnabledCapabilityRegistry(
62
+ { ...config(), enabledCapabilities: ['desktop'] },
63
+ () => null,
64
+ );
65
+ expect(registry.getCapabilityNames()).toEqual([]);
66
+ expect(registry.getHandler('desktop.cua.get_screen_size')).toBeNull();
67
+ });
68
+
69
+ test('desktop is advertised when a trusted local driver exists', () => {
70
+ const registry = createEnabledCapabilityRegistry(
71
+ { ...config(), enabledCapabilities: ['desktop'] },
72
+ () => '/trusted/cua-driver',
73
+ );
74
+ expect(registry.getCapabilityNames()).toEqual(['desktop']);
75
+ expect(registry.getHandler('desktop.cua.get_screen_size')).not.toBeNull();
76
+ });
77
+ test('a permission scope cannot widen the local filesystem ceiling', async () => {
78
+ const handler = createFilesystemCapability(config()).methods.get('fs.read')!;
79
+ await expect(
80
+ handler({
81
+ path: join(outside, 'secret.txt'),
82
+ __permission: {
83
+ permissionId: 'permission-1',
84
+ capability: 'filesystem',
85
+ scope: { paths: [outside], operations: ['read'] },
86
+ },
87
+ }),
88
+ ).rejects.toThrow('outside allowed directories');
89
+ });
90
+
91
+ test('filesystem operations are checked again on the machine', async () => {
92
+ const handler = createFilesystemCapability(config()).methods.get('fs.read')!;
93
+ await expect(
94
+ handler({
95
+ path: join(root, 'allowed.txt'),
96
+ __permission: {
97
+ permissionId: 'permission-1',
98
+ capability: 'filesystem',
99
+ scope: { operations: ['write'] },
100
+ },
101
+ }),
102
+ ).rejects.toThrow('operation "read" is not allowed');
103
+ });
104
+
105
+ test('shell command and working-directory scopes are checked on the machine', async () => {
106
+ const handler = createShellCapability(config()).methods.get('shell.exec')!;
107
+ await expect(
108
+ handler({
109
+ command: 'node',
110
+ args: ['--version'],
111
+ cwd: outside,
112
+ __permission: {
113
+ permissionId: 'permission-1',
114
+ capability: 'shell',
115
+ scope: { commands: ['node'], workingDir: root },
116
+ },
117
+ }),
118
+ ).rejects.toThrow('outside allowed directories');
119
+ });
120
+
121
+ test('disjoint local and permission command allowlists fail closed', async () => {
122
+ const handler = createShellCapability({ ...config(), allowedCommands: ['node'] }).methods.get(
123
+ 'shell.exec',
124
+ )!;
125
+ await expect(
126
+ handler({
127
+ command: 'sh',
128
+ args: ['-c', 'echo must-not-run'],
129
+ __permission: {
130
+ permissionId: 'permission-1',
131
+ capability: 'shell',
132
+ scope: { commands: ['sh'] },
133
+ },
134
+ }),
135
+ ).rejects.toThrow('not in the allowed commands list');
136
+ });
137
+
138
+ test('desktop feature scopes deny before invoking cua-driver', async () => {
139
+ const handler = createDesktopCapability().methods.get('desktop.cua.click')!;
140
+ await expect(
141
+ handler({
142
+ x: 10,
143
+ y: 10,
144
+ __permission: {
145
+ permissionId: 'permission-1',
146
+ capability: 'desktop',
147
+ scope: { features: ['screenshot'] },
148
+ },
149
+ }),
150
+ ).rejects.toThrow('desktop feature "mouse" is not allowed');
151
+ });
152
+
153
+ test('remote desktop calls cannot trigger mutable installer or update tools', async () => {
154
+ const capability = createDesktopCapability();
155
+ expect(capability.methods.has('desktop.cua.check_for_update')).toBe(false);
156
+ expect(capability.methods.has('desktop.cua.install_ffmpeg')).toBe(false);
157
+
158
+ const call = capability.methods.get('desktop.cua.call')!;
159
+ await expect(
160
+ call({
161
+ tool: 'check_for_update',
162
+ __permission: {
163
+ permissionId: 'permission-1',
164
+ capability: 'desktop',
165
+ scope: { features: ['computer_use'] },
166
+ },
167
+ }),
168
+ ).rejects.toThrow('local-only');
169
+ });
170
+
171
+ test('cua-driver receives neither tunnel environment secrets nor internal permission data', async () => {
172
+ if (process.platform === 'win32') return;
173
+ const binary = join(root, 'fake-cua-driver');
174
+ await writeFile(
175
+ binary,
176
+ [
177
+ '#!/usr/bin/env node',
178
+ 'process.stdout.write(JSON.stringify({',
179
+ ' token: process.env.TUNNEL_TOKEN ?? null,',
180
+ ' args: process.argv.slice(2),',
181
+ '}));',
182
+ ].join('\n'),
183
+ );
184
+ await chmod(binary, 0o700);
185
+ const previousBinary = process.env.CUA_DRIVER_BIN;
186
+ const previousToken = process.env.TUNNEL_TOKEN;
187
+ process.env.CUA_DRIVER_BIN = binary;
188
+ process.env.TUNNEL_TOKEN = 'must-not-leak';
189
+
190
+ try {
191
+ const result = (await new CuaDriver().call('click', {
192
+ x: 1,
193
+ __permission: { permissionId: 'private-permission-id' },
194
+ })) as { token: string | null; args: string[] };
195
+ expect(result.token).toBeNull();
196
+ expect(result.args.join(' ')).not.toContain('private-permission-id');
197
+ } finally {
198
+ if (previousBinary === undefined) delete process.env.CUA_DRIVER_BIN;
199
+ else process.env.CUA_DRIVER_BIN = previousBinary;
200
+ if (previousToken === undefined) delete process.env.TUNNEL_TOKEN;
201
+ else process.env.TUNNEL_TOKEN = previousToken;
202
+ }
203
+ });
204
+ });
@@ -13,6 +13,21 @@ import type { Capability, RpcHandler } from './index';
13
13
  import { validateCommand } from '../security/command-validator';
14
14
  import { validatePath } from '../security/path-validator';
15
15
  import type { TunnelConfig } from '../config';
16
+ import type { LocalPermission } from '../security/permission-guard';
17
+
18
+ interface LocalShellScope {
19
+ commands?: string[];
20
+ workingDir?: string;
21
+ maxTimeout?: number;
22
+ }
23
+
24
+ function permissionShellScope(params: Record<string, unknown>): LocalShellScope {
25
+ const permission = params.__permission as LocalPermission | undefined;
26
+ if (permission?.capability !== 'shell') {
27
+ throw new Error('Permission denied: shell permission required');
28
+ }
29
+ return (permission.scope ?? {}) as LocalShellScope;
30
+ }
16
31
 
17
32
  export function createShellCapability(config: TunnelConfig): Capability {
18
33
  const methods = new Map<string, RpcHandler>();
@@ -20,16 +35,35 @@ export function createShellCapability(config: TunnelConfig): Capability {
20
35
  methods.set('shell.exec', async (params) => {
21
36
  const command = params.command as string;
22
37
  const args = (params.args as string[]) || [];
38
+ if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) {
39
+ throw new Error('Command args must be an array of strings');
40
+ }
41
+ const scope = permissionShellScope(params);
23
42
  const cwd = (params.cwd as string) || config.workingDir;
43
+ const requestedTimeout =
44
+ params.timeout === undefined ? config.shellTimeout : Number(params.timeout);
45
+ if (!Number.isFinite(requestedTimeout) || requestedTimeout <= 0) {
46
+ throw new Error('Command timeout must be a positive number');
47
+ }
24
48
  const timeout = Math.min(
25
- (params.timeout as number) || config.shellTimeout,
49
+ requestedTimeout,
26
50
  config.shellMaxTimeout,
51
+ typeof scope.maxTimeout === 'number' ? scope.maxTimeout : config.shellMaxTimeout,
27
52
  );
28
53
 
29
- validateCommand(command, config.allowedCommands, config.blockedCommands);
54
+ const scopedCommands = Array.isArray(scope.commands)
55
+ ? scope.commands.filter((value): value is string => typeof value === 'string')
56
+ : [];
57
+ const executable = validateCommand(command, config.allowedCommands, config.blockedCommands);
58
+ if (scopedCommands.length > 0) {
59
+ validateCommand(executable, scopedCommands, []);
60
+ }
30
61
 
31
62
  if (cwd) {
32
63
  validatePath(cwd, config.allowedPaths, config.blockedPaths);
64
+ if (typeof scope.workingDir === 'string' && scope.workingDir.length > 0) {
65
+ validatePath(cwd, [scope.workingDir], config.blockedPaths);
66
+ }
33
67
  }
34
68
 
35
69
  const safeEnv: Record<string, string> = { TERM: 'dumb' };
@@ -40,7 +74,7 @@ export function createShellCapability(config: TunnelConfig): Capability {
40
74
  }
41
75
 
42
76
  return new Promise((resolve, reject) => {
43
- const proc = spawn(command, args, {
77
+ const proc = spawn(executable, args, {
44
78
  cwd,
45
79
  shell: false,
46
80
  timeout,
@@ -0,0 +1,179 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { access, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+
7
+ const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
8
+ const children = new Set<ChildProcess>();
9
+ const temporaryHomes = new Set<string>();
10
+
11
+ afterEach(async () => {
12
+ for (const child of children) child.kill('SIGTERM');
13
+ children.clear();
14
+ await Promise.all(
15
+ [...temporaryHomes].map((path) => rm(path, { recursive: true, force: true })),
16
+ );
17
+ temporaryHomes.clear();
18
+ });
19
+
20
+ async function waitForFile(path: string, timeoutMs = 5_000): Promise<void> {
21
+ const deadline = Date.now() + timeoutMs;
22
+ while (Date.now() < deadline) {
23
+ try {
24
+ await stat(path);
25
+ return;
26
+ } catch {
27
+ await Bun.sleep(20);
28
+ }
29
+ }
30
+ throw new Error(`Timed out waiting for ${path}`);
31
+ }
32
+
33
+ describe('agent tunnel device authorization CLI', () => {
34
+ test('persists the exact browser-approved capability list as the local ceiling', async () => {
35
+ const approvedCapabilities = ['desktop', 'filesystem'];
36
+ const server = Bun.serve({
37
+ port: 0,
38
+ fetch(request) {
39
+ const url = new URL(request.url);
40
+ if (request.method === 'POST' && url.pathname === '/v1/tunnel/device-auth') {
41
+ return Response.json(
42
+ {
43
+ deviceCode: 'TEST-0001',
44
+ deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
45
+ verificationUrl: 'https://dev.kortix.com/tunnel/authorize/TEST-0001',
46
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
47
+ pollIntervalMs: 250,
48
+ },
49
+ { status: 201 },
50
+ );
51
+ }
52
+ if (request.method === 'GET' && url.pathname.endsWith('/TEST-0001/status')) {
53
+ expect(request.headers.get('authorization')).toBe(
54
+ 'Bearer ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
55
+ );
56
+ return Response.json({
57
+ status: 'approved',
58
+ tunnelId: '00000000-0000-4000-8000-000000000001',
59
+ token: 'kortix_tnl_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
60
+ capabilities: approvedCapabilities,
61
+ });
62
+ }
63
+ return new Response('not found', { status: 404 });
64
+ },
65
+ });
66
+
67
+ const temporaryHome = await mkdtemp(join(tmpdir(), 'agent-tunnel-cli-home-'));
68
+ temporaryHomes.add(temporaryHome);
69
+ const child = spawn(
70
+ process.execPath,
71
+ [
72
+ 'run',
73
+ CLI_PATH,
74
+ 'connect',
75
+ '--foreground',
76
+ '--api-url',
77
+ `http://127.0.0.1:${server.port}/v1/tunnel`,
78
+ ],
79
+ {
80
+ env: {
81
+ ...process.env,
82
+ HOME: temporaryHome,
83
+ KORTIX_AGENT_TUNNEL_NO_BROWSER: '1',
84
+ },
85
+ stdio: ['ignore', 'pipe', 'pipe'],
86
+ },
87
+ );
88
+ children.add(child);
89
+
90
+ try {
91
+ const configPath = join(temporaryHome, '.agent-tunnel', 'config.json');
92
+ await waitForFile(configPath);
93
+ const config = JSON.parse(await readFile(configPath, 'utf8')) as {
94
+ enabledCapabilities?: string[];
95
+ };
96
+ expect(config.enabledCapabilities).toEqual(approvedCapabilities);
97
+ expect((await stat(configPath)).mode & 0o077).toBe(0);
98
+ } finally {
99
+ child.kill('SIGTERM');
100
+ children.delete(child);
101
+ server.stop(true);
102
+ }
103
+ });
104
+
105
+ test('rejects malformed credentials returned by the authorization server', async () => {
106
+ let approvedResponseSent!: () => void;
107
+ const responseSent = new Promise<void>((resolve) => {
108
+ approvedResponseSent = resolve;
109
+ });
110
+ const server = Bun.serve({
111
+ port: 0,
112
+ fetch(request) {
113
+ const url = new URL(request.url);
114
+ if (request.method === 'POST' && url.pathname === '/v1/tunnel/device-auth') {
115
+ return Response.json(
116
+ {
117
+ deviceCode: 'TEST-0002',
118
+ deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
119
+ verificationUrl: 'https://dev.kortix.com/tunnel/authorize/TEST-0002',
120
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
121
+ pollIntervalMs: 250,
122
+ },
123
+ { status: 201 },
124
+ );
125
+ }
126
+ if (request.method === 'GET' && url.pathname.endsWith('/TEST-0002/status')) {
127
+ approvedResponseSent();
128
+ return Response.json({
129
+ status: 'approved',
130
+ tunnelId: 'not-a-uuid',
131
+ token: 'kortix_tnl_invalid\ncredential',
132
+ capabilities: ['desktop'],
133
+ });
134
+ }
135
+ return new Response('not found', { status: 404 });
136
+ },
137
+ });
138
+
139
+ const temporaryHome = await mkdtemp(join(tmpdir(), 'agent-tunnel-cli-home-'));
140
+ temporaryHomes.add(temporaryHome);
141
+ const configPath = join(temporaryHome, '.agent-tunnel', 'config.json');
142
+ const child = spawn(
143
+ process.execPath,
144
+ [
145
+ 'run',
146
+ CLI_PATH,
147
+ 'connect',
148
+ '--foreground',
149
+ '--api-url',
150
+ `http://127.0.0.1:${server.port}/v1/tunnel`,
151
+ ],
152
+ {
153
+ env: {
154
+ ...process.env,
155
+ HOME: temporaryHome,
156
+ KORTIX_AGENT_TUNNEL_NO_BROWSER: '1',
157
+ },
158
+ stdio: ['ignore', 'pipe', 'pipe'],
159
+ },
160
+ );
161
+ children.add(child);
162
+ let stderr = '';
163
+ child.stderr?.on('data', (chunk) => {
164
+ stderr += String(chunk);
165
+ });
166
+ const exitCode = new Promise<number | null>((resolve) => child.once('exit', resolve));
167
+
168
+ try {
169
+ await responseSent;
170
+ expect(await Promise.race([exitCode, Bun.sleep(2_000).then(() => -1)])).toBe(1);
171
+ await expect(access(configPath)).rejects.toThrow();
172
+ expect(stderr).toContain('invalid tunnel ID');
173
+ } finally {
174
+ child.kill('SIGTERM');
175
+ children.delete(child);
176
+ server.stop(true);
177
+ }
178
+ });
179
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { spawnSync } from 'child_process';
3
+ import { resolve } from 'path';
4
+
5
+ const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
6
+
7
+ describe('agent tunnel service UX', () => {
8
+ test('offers persistent daemon mode without an unsupported keep-awake flag', () => {
9
+ const result = spawnSync('bun', ['run', CLI_PATH, 'help'], { encoding: 'utf8' });
10
+
11
+ expect(result.status).toBe(0);
12
+ expect(result.stdout).toContain('--daemon');
13
+ expect(result.stdout).toContain('--foreground');
14
+ expect(result.stdout).not.toContain('--keep-awake');
15
+ });
16
+
17
+ test('rejects the removed keep-awake flag instead of silently ignoring it', () => {
18
+ const result = spawnSync('bun', ['run', CLI_PATH, 'connect', '--keep-awake'], {
19
+ encoding: 'utf8',
20
+ });
21
+
22
+ expect(result.status).toBe(2);
23
+ expect(result.stderr).toContain('--keep-awake is not supported');
24
+ });
25
+ });