@kortix/agent-tunnel 0.13.53 → 0.13.55

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.
@@ -1,5 +1,5 @@
1
1
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs';
2
- import { platform } from 'os';
2
+ import { homedir, platform } from 'os';
3
3
  import { join } from 'path';
4
4
 
5
5
  import {
@@ -128,6 +128,25 @@ export function runnerPartsFor(
128
128
  return { command: runtime.execPath, args: [vendor(script, paths), 'run', '--service'], env };
129
129
  }
130
130
 
131
+ /**
132
+ * The Kortix desktop app's own agent runtime, when the app is installed on
133
+ * this Mac. A CLI install runs on it, so the connection is the same agent
134
+ * build with the same bundled driver, and macOS grants Accessibility and
135
+ * Screen Recording to Kortix once for both, never to node or a terminal.
136
+ */
137
+ export function installedAppRunner(
138
+ apps: readonly string[] = ['/Applications/Kortix.app', join(homedir(), 'Applications', 'Kortix.app')],
139
+ os: string = process.platform,
140
+ ): { execPath: string; script: string } | null {
141
+ if (os !== 'darwin') return null;
142
+ for (const app of apps) {
143
+ const execPath = join(app, 'Contents', 'MacOS', 'Kortix');
144
+ const script = join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js');
145
+ if (existsSync(execPath) && existsSync(script)) return { execPath, script };
146
+ }
147
+ return null;
148
+ }
149
+
131
150
  /** Where vendorRunner WOULD put the bundle, without copying anything. */
132
151
  const plannedRunner = (script: string, paths: ServicePaths) =>
133
152
  isEphemeralRunnerPath(script) ? paths.vendoredRunner : script;
@@ -135,6 +154,8 @@ const plannedRunner = (script: string, paths: ServicePaths) =>
135
154
  function currentRunnerParts(
136
155
  vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
137
156
  ): RunnerParts {
157
+ const app = process.versions.electron ? null : installedAppRunner();
158
+ if (app) return runnerPartsFor(app.script, { execPath: app.execPath, electron: 'installed-app' }, getServicePaths(), vendor);
138
159
  const script = process.argv[1];
139
160
  if (script && existsSync(script)) return runnerPartsFor(script, undefined, getServicePaths(), vendor);
140
161
  throw new Error(
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
2
+ import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
@@ -29,4 +29,14 @@ describe('agent state file', () => {
29
29
  rmSync(join(home, '..'), { recursive: true, force: true });
30
30
  }
31
31
  });
32
+
33
+ test('a writer that died without writing offline reads as offline', () => {
34
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-state-'));
35
+ try {
36
+ writeFileSync(stateFilePath(home), JSON.stringify({ tunnelId: 't', apiUrl: 'u', status: 'online', since: '', agentVersion: '0', pid: 2 ** 22 + 7 }));
37
+ expect(readAgentState(home)?.status).toBe('offline');
38
+ } finally {
39
+ rmSync(home, { recursive: true, force: true });
40
+ }
41
+ });
32
42
  });
@@ -48,10 +48,22 @@ export function writeAgentState(
48
48
  }
49
49
  }
50
50
 
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
+ /** The last status the agent wrote. A writer that died without writing `offline` reads as offline. */
51
62
  export function readAgentState(home: string = agentTunnelHome()): AgentStateFile | null {
52
63
  try {
53
64
  const parsed = JSON.parse(readFileSync(stateFilePath(home), 'utf8')) as AgentStateFile;
54
- return parsed && typeof parsed === 'object' && typeof parsed.status === 'string' ? parsed : null;
65
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.status !== 'string') return null;
66
+ return parsed.status !== 'offline' && !isAlive(parsed.pid) ? { ...parsed, status: 'offline' } : parsed;
55
67
  } catch {
56
68
  return null;
57
69
  }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export {
9
9
  TunnelErrorCode,
10
10
  TunnelMethods,
11
11
  capabilityForMethod,
12
- desktopFeatureForMethod,
12
+ normalizeDesktopCall,
13
13
  isTunnelCapability,
14
14
  operationForMethod,
15
15
  validateTunnelPermissionScope,
@@ -35,7 +35,7 @@ export type {
35
35
  export { TunnelErrorCode, TunnelMethods } from './types';
36
36
  export {
37
37
  capabilityForMethod,
38
- desktopFeatureForMethod,
38
+ normalizeDesktopCall,
39
39
  isTunnelCapability,
40
40
  operationForMethod,
41
41
  validateTunnelPermissionScope,
@@ -19,54 +19,35 @@ export interface PermissionScopeValidationResult {
19
19
  sanitized?: Record<string, unknown>;
20
20
  }
21
21
 
22
- const DESKTOP_METHOD_FEATURES: Readonly<Record<string, string>> = {
23
- 'desktop.cua.ensure': 'computer_use',
24
- 'desktop.cua.start_daemon': 'computer_use',
25
- 'desktop.cua.status': 'computer_use',
26
- 'desktop.cua.version': 'computer_use',
27
- 'desktop.cua.list_tools': 'computer_use',
28
- 'desktop.cua.describe': 'computer_use',
29
- 'desktop.cua.bring_to_front': 'windows',
30
- 'desktop.cua.check_for_update': 'computer_use',
31
- 'desktop.cua.check_permissions': 'computer_use',
32
- 'desktop.cua.click': 'mouse',
33
- 'desktop.cua.double_click': 'mouse',
34
- 'desktop.cua.drag': 'mouse',
35
- 'desktop.cua.end_session': 'computer_use',
36
- 'desktop.cua.get_accessibility_tree': 'accessibility',
37
- 'desktop.cua.get_agent_cursor_state': 'mouse',
38
- 'desktop.cua.get_config': 'computer_use',
39
- 'desktop.cua.get_cursor_position': 'mouse',
40
- 'desktop.cua.get_recording_state': 'computer_use',
41
- 'desktop.cua.get_screen_size': 'screenshot',
42
- 'desktop.cua.get_window_state': 'accessibility',
43
- 'desktop.cua.hotkey': 'keyboard',
44
- 'desktop.cua.kill_app': 'apps',
45
- 'desktop.cua.launch_app': 'apps',
46
- 'desktop.cua.list_apps': 'apps',
47
- 'desktop.cua.list_windows': 'windows',
48
- 'desktop.cua.move_cursor': 'mouse',
49
- 'desktop.cua.page': 'accessibility',
50
- 'desktop.cua.press_key': 'keyboard',
51
- 'desktop.cua.replay_trajectory': 'computer_use',
52
- 'desktop.cua.right_click': 'mouse',
53
- 'desktop.cua.scroll': 'keyboard',
54
- 'desktop.cua.set_agent_cursor_enabled': 'mouse',
55
- 'desktop.cua.set_agent_cursor_motion': 'mouse',
56
- 'desktop.cua.set_agent_cursor_style': 'mouse',
57
- 'desktop.cua.set_config': 'computer_use',
58
- 'desktop.cua.set_value': 'accessibility',
59
- 'desktop.cua.start_recording': 'screenshot',
60
- 'desktop.cua.install_ffmpeg': 'computer_use',
61
- 'desktop.cua.start_session': 'computer_use',
62
- 'desktop.cua.stop_recording': 'screenshot',
63
- 'desktop.cua.type_text': 'keyboard',
64
- 'desktop.cua.zoom': 'screenshot',
65
- };
22
+ const CUA_PREFIX = 'desktop.cua.';
66
23
 
24
+ /** Every `desktop.cua.<tool>` is the desktop capability: the driver owns the tool list. */
67
25
  export function capabilityForMethod(method: string): TunnelCapability | null {
68
26
  const capability = (TunnelMethods as Readonly<Record<string, TunnelCapability | null>>)[method];
69
- return capability ?? null;
27
+ if (capability !== undefined) return capability;
28
+ return method.startsWith(CUA_PREFIX) && method.length > CUA_PREFIX.length ? 'desktop' : null;
29
+ }
30
+
31
+ /**
32
+ * `desktop.cua.<tool>` → `desktop.cua.call { tool, args }`. Every agent since
33
+ * 0.1.0 serves `desktop.cua.call` for any driver tool, so one wire method
34
+ * reaches every tool the installed driver has, with no per-tool list to keep
35
+ * in sync. Management methods (`ensure`, `status`, `describe`, …) pass through.
36
+ */
37
+ export function normalizeDesktopCall(
38
+ method: string,
39
+ params: Record<string, unknown>,
40
+ ): { method: string; params: Record<string, unknown> } {
41
+ if (!method.startsWith(CUA_PREFIX) || method in TunnelMethods) return { method, params };
42
+ const { permissionId, ...args } = params;
43
+ return {
44
+ method: 'desktop.cua.call',
45
+ params: {
46
+ tool: method.slice(CUA_PREFIX.length),
47
+ args,
48
+ ...(permissionId === undefined ? {} : { permissionId }),
49
+ },
50
+ };
70
51
  }
71
52
 
72
53
  export function isTunnelCapability(value: string): value is TunnelCapability {
@@ -277,16 +258,3 @@ export function operationForMethod(method: string): string {
277
258
  const separator = method.indexOf('.');
278
259
  return separator === -1 ? method : method.slice(separator + 1);
279
260
  }
280
-
281
- export function desktopFeatureForMethod(
282
- method: string,
283
- args: Record<string, unknown> = {},
284
- ): string | undefined {
285
- if (method === 'desktop.cua.call') {
286
- const tool = args.tool;
287
- if (typeof tool !== 'string' || tool.length === 0) return undefined;
288
- const toolMethod = tool.startsWith('desktop.cua.') ? tool : `desktop.cua.${tool}`;
289
- return DESKTOP_METHOD_FEATURES[toolMethod];
290
- }
291
- return DESKTOP_METHOD_FEATURES[method];
292
- }
@@ -48,6 +48,10 @@ export const TunnelErrorCode = {
48
48
  ACCESS_DENIED: -32011,
49
49
  /** The owner turned computer access off on the machine. */
50
50
  ACCESS_OFF: -32012,
51
+ /** macOS has not given the agent Accessibility or Screen Recording. */
52
+ DESKTOP_PERMISSION_MISSING: -32013,
53
+ /** A wider access asked for through Kortix that nobody confirmed on the machine. */
54
+ ACCESS_UNCONFIRMED: -32014,
51
55
  } as const;
52
56
 
53
57
  export type TunnelErrorCodeValue = (typeof TunnelErrorCode)[keyof typeof TunnelErrorCode];
@@ -68,47 +72,13 @@ export const TunnelMethods = {
68
72
  'desktop.cua.list_tools': 'desktop',
69
73
  'desktop.cua.describe': 'desktop',
70
74
  'desktop.cua.call': 'desktop',
71
- 'desktop.cua.bring_to_front': 'desktop',
72
- 'desktop.cua.check_for_update': 'desktop',
73
- 'desktop.cua.check_permissions': 'desktop',
74
- 'desktop.cua.click': 'desktop',
75
- 'desktop.cua.double_click': 'desktop',
76
- 'desktop.cua.drag': 'desktop',
77
- 'desktop.cua.end_session': 'desktop',
78
- 'desktop.cua.get_accessibility_tree': 'desktop',
79
- 'desktop.cua.get_agent_cursor_state': 'desktop',
80
- 'desktop.cua.get_config': 'desktop',
81
- 'desktop.cua.get_cursor_position': 'desktop',
82
- 'desktop.cua.get_recording_state': 'desktop',
83
- 'desktop.cua.get_screen_size': 'desktop',
84
- 'desktop.cua.get_window_state': 'desktop',
85
- 'desktop.cua.hotkey': 'desktop',
86
- 'desktop.cua.kill_app': 'desktop',
87
- 'desktop.cua.launch_app': 'desktop',
88
- 'desktop.cua.list_apps': 'desktop',
89
- 'desktop.cua.list_windows': 'desktop',
90
- 'desktop.cua.move_cursor': 'desktop',
91
- 'desktop.cua.page': 'desktop',
92
- 'desktop.cua.press_key': 'desktop',
93
- 'desktop.cua.replay_trajectory': 'desktop',
94
- 'desktop.cua.right_click': 'desktop',
95
- 'desktop.cua.scroll': 'desktop',
96
- 'desktop.cua.set_agent_cursor_enabled': 'desktop',
97
- 'desktop.cua.set_agent_cursor_motion': 'desktop',
98
- 'desktop.cua.set_agent_cursor_style': 'desktop',
99
- 'desktop.cua.set_config': 'desktop',
100
- 'desktop.cua.set_value': 'desktop',
101
- 'desktop.cua.start_recording': 'desktop',
102
- 'desktop.cua.install_ffmpeg': 'desktop',
103
- 'desktop.cua.start_session': 'desktop',
104
- 'desktop.cua.stop_recording': 'desktop',
105
- 'desktop.cua.type_text': 'desktop',
106
- 'desktop.cua.zoom': 'desktop',
107
75
  'tunnel.ping': null,
108
76
  'tunnel.pong': null,
109
77
  'tunnel.permission.revoked': null,
110
78
  'tunnel.permissions.sync': null,
111
79
  'tunnel.token.rotated': null,
80
+ /** The owner's access change, relayed by Kortix (owner-only route). Never a capability. */
81
+ 'tunnel.access.set': null,
112
82
  } as const;
113
83
 
114
84
  export type TunnelMethod = keyof typeof TunnelMethods;
@@ -195,6 +165,8 @@ export interface TunnelAuthMessage {
195
165
  * that enforces nothing connects with the same credential.
196
166
  */
197
167
  reportsAccess?: boolean;
168
+ /** True from agents that serve `tunnel.access.set` (the owner's change, relayed). */
169
+ controlsAccess?: boolean;
198
170
  }
199
171
 
200
172
  /** Result returned by onAuthenticate hook on success. */