@kortix/agent-tunnel 0.1.4 → 0.12.8

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
@@ -1,89 +0,0 @@
1
- import { EventEmitter } from 'events';
2
- import { n as TunnelRelayConfig, o as TunnelRelayEvents, A as AgentInfo, R as RelayRpcOptions, H as HeartbeatConfig, a as AuthResult, q as TunnelServerConfig } from '../types-Dpwrd8Ai.js';
3
- import { Hono } from 'hono';
4
-
5
- declare class TunnelRelay extends EventEmitter {
6
- private agents;
7
- private pendingRPCs;
8
- private config;
9
- onAuthorizeRPC?: (tunnelId: string, method: string, params: Record<string, unknown>) => Promise<boolean>;
10
- constructor(config?: TunnelRelayConfig);
11
- emitEvent<K extends keyof TunnelRelayEvents>(event: K, data: TunnelRelayEvents[K]): boolean;
12
- registerAgent(tunnelId: string, ws: WebSocket, signingKey: string, metadata?: Record<string, unknown>): void;
13
- unregisterAgent(tunnelId: string): void;
14
- isConnected(tunnelId: string): boolean;
15
- getConnectedCount(): number;
16
- getConnectedAgents(): Map<string, AgentInfo>;
17
- getAgentMetadata(tunnelId: string): Record<string, unknown> | undefined;
18
- handleAgentMessage(tunnelId: string, raw: string | Buffer): void;
19
- relayRPC(tunnelId: string, method: string, params: Record<string, unknown>, options?: RelayRpcOptions): Promise<unknown>;
20
- sendNotification(tunnelId: string, method: string, params?: Record<string, unknown>): boolean;
21
- shutdown(): void;
22
- }
23
- declare class TunnelRelayError extends Error {
24
- readonly code: number;
25
- readonly data?: unknown | undefined;
26
- constructor(code: number, message: string, data?: unknown | undefined);
27
- }
28
-
29
- declare class HeartbeatManager {
30
- private states;
31
- private intervalHandle;
32
- private relay;
33
- private intervalMs;
34
- private maxMissed;
35
- constructor(relay: TunnelRelay, config?: HeartbeatConfig);
36
- start(): void;
37
- stop(): void;
38
- register(tunnelId: string): void;
39
- unregister(tunnelId: string): void;
40
- recordPong(tunnelId: string): void;
41
- private tick;
42
- }
43
-
44
- interface WsHandlerOptions {
45
- heartbeat?: HeartbeatManager;
46
- maxMessageSize?: number;
47
- onAuthenticate?: (tunnelId: string, token: string) => Promise<AuthResult | null>;
48
- authTimeoutMs?: number;
49
- }
50
- interface WsHandlers {
51
- onOpen(tunnelId: string, ws: WebSocket): void;
52
- onMessage(tunnelId: string, message: string | Buffer): void;
53
- onClose(tunnelId: string): void;
54
- }
55
- declare function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): WsHandlers;
56
-
57
- declare function createTunnelRouter(relay: TunnelRelay, onAuthorizeHTTP?: (req: Request) => Promise<boolean>): Hono;
58
-
59
- /**
60
- * Standalone tunnel server — one function call to get a working relay.
61
- *
62
- * Usage:
63
- * import { startTunnelServer } from 'agent-tunnel';
64
- * const server = startTunnelServer({ port: 8080, onAuthenticate: ... });
65
- *
66
- * Agents connect via: ws://localhost:8080/ws?tunnelId=yyy
67
- * Then send { type: "auth", token: "tnl_xxx" } as the first message.
68
- * Your app calls relay.relayRPC(tunnelId, method, params) to reach the agent.
69
- */
70
-
71
- interface TunnelServer {
72
- app: Hono;
73
- relay: TunnelRelay;
74
- heartbeat: HeartbeatManager;
75
- wsHandlers: ReturnType<typeof createWsHandlers>;
76
- stop: () => void;
77
- }
78
- /**
79
- * Start a tunnel relay server. Handles everything:
80
- * - HTTP routes for connections list + RPC
81
- * - WebSocket upgrades on /ws for agent connections
82
- * - Heartbeat ping/pong
83
- * - HMAC signing key derivation via onAuthenticate hook
84
- *
85
- * Requires Bun runtime for WebSocket server support.
86
- */
87
- declare function startTunnelServer(config?: TunnelServerConfig): TunnelServer;
88
-
89
- export { HeartbeatManager, TunnelRelay, TunnelRelayError, type TunnelServer, type WsHandlerOptions, type WsHandlers, createTunnelRouter, createWsHandlers, startTunnelServer };
@@ -1,14 +0,0 @@
1
- import { TunnelRelay, TunnelRelayError } from "./relay";
2
- import { HeartbeatManager } from "./heartbeat";
3
- import { createWsHandlers } from "./ws-handler";
4
- import { createTunnelRouter } from "./routes";
5
- import { startTunnelServer } from "./server";
6
- export {
7
- HeartbeatManager,
8
- TunnelRelay,
9
- TunnelRelayError,
10
- createTunnelRouter,
11
- createWsHandlers,
12
- startTunnelServer
13
- };
14
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["export { TunnelRelay, TunnelRelayError } from './relay';\nexport { HeartbeatManager } from './heartbeat';\nexport { createWsHandlers } from './ws-handler';\nexport type { WsHandlers, WsHandlerOptions } from './ws-handler';\nexport { createTunnelRouter } from './routes';\nexport { startTunnelServer } from './server';\nexport type { TunnelServer } from './server';\n"],"mappings":"AAAA,SAAS,aAAa,wBAAwB;AAC9C,SAAS,wBAAwB;AACjC,SAAS,wBAAwB;AAEjC,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;","names":[]}
@@ -1,10 +0,0 @@
1
- export { A as AgentInfo, a as AuthResult, H as HeartbeatConfig, J as JsonRpcError, b as JsonRpcErrorResponse, c as JsonRpcMessage, d as JsonRpcNotification, e as JsonRpcRequest, f as JsonRpcResponse, g as JsonRpcSuccessResponse, P as PendingRPC, R as RelayRpcOptions, S as SignedJsonRpcNotification, h as SignedJsonRpcRequest, T as TunnelAuthMessage, i as TunnelCapability, j as TunnelErrorCode, k as TunnelErrorCodeValue, l as TunnelMethod, m as TunnelMethods, n as TunnelRelayConfig, o as TunnelRelayEvents, p as TunnelRpcParams, q as TunnelServerConfig } from '../types-Dpwrd8Ai.js';
2
-
3
- declare function deriveSigningKey(token: string, secret: string): string;
4
- declare function signMessage(signingKey: string, payload: string, nonce: number): string;
5
- declare function verifyMessageSignature(signingKey: string, payload: string, nonce: number, signature: string): boolean;
6
- declare function generateToken(prefix?: string): string;
7
- declare function hashToken(token: string, secret: string): string;
8
- declare function timingSafeStringEqual(a: string, b: string): boolean;
9
-
10
- export { deriveSigningKey, generateToken, hashToken, signMessage, timingSafeStringEqual, verifyMessageSignature };
@@ -1,20 +0,0 @@
1
- import {
2
- deriveSigningKey,
3
- signMessage,
4
- verifyMessageSignature,
5
- generateToken,
6
- hashToken,
7
- timingSafeStringEqual
8
- } from "./crypto";
9
- import { TunnelErrorCode, TunnelMethods } from "./types";
10
- export {
11
- TunnelErrorCode,
12
- TunnelMethods,
13
- deriveSigningKey,
14
- generateToken,
15
- hashToken,
16
- signMessage,
17
- timingSafeStringEqual,
18
- verifyMessageSignature
19
- };
20
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/shared/index.ts"],"sourcesContent":["export {\n deriveSigningKey,\n signMessage,\n verifyMessageSignature,\n generateToken,\n hashToken,\n timingSafeStringEqual,\n} from './crypto';\n\nexport type {\n JsonRpcRequest,\n JsonRpcSuccessResponse,\n JsonRpcErrorResponse,\n JsonRpcError,\n JsonRpcResponse,\n JsonRpcNotification,\n JsonRpcMessage,\n SignedJsonRpcRequest,\n SignedJsonRpcNotification,\n PendingRPC,\n TunnelRpcParams,\n RelayRpcOptions,\n AgentInfo,\n TunnelRelayEvents,\n TunnelRelayConfig,\n HeartbeatConfig,\n TunnelServerConfig,\n TunnelAuthMessage,\n AuthResult,\n TunnelCapability,\n TunnelMethod,\n TunnelErrorCodeValue,\n} from './types';\n\nexport { TunnelErrorCode, TunnelMethods } from './types';\n"],"mappings":"AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AA2BP,SAAS,iBAAiB,qBAAqB;","names":[]}
@@ -1,194 +0,0 @@
1
- interface JsonRpcRequest {
2
- jsonrpc: '2.0';
3
- id: string;
4
- method: string;
5
- params?: Record<string, unknown>;
6
- }
7
- interface JsonRpcSuccessResponse {
8
- jsonrpc: '2.0';
9
- id: string;
10
- result: unknown;
11
- }
12
- interface JsonRpcErrorResponse {
13
- jsonrpc: '2.0';
14
- id: string;
15
- error: JsonRpcError;
16
- }
17
- interface JsonRpcError {
18
- code: number;
19
- message: string;
20
- data?: unknown;
21
- }
22
- type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
23
- interface JsonRpcNotification {
24
- jsonrpc: '2.0';
25
- method: string;
26
- params?: Record<string, unknown>;
27
- }
28
- type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
29
- declare const TunnelErrorCode: {
30
- readonly PERMISSION_DENIED: -32000;
31
- readonly CAPABILITY_NOT_REGISTERED: -32001;
32
- readonly TIMEOUT: -32002;
33
- readonly LOCAL_ERROR: -32003;
34
- readonly NOT_CONNECTED: -32004;
35
- readonly EXPIRED: -32005;
36
- readonly RATE_LIMITED: -32006;
37
- readonly AUTH_FAILED: -32007;
38
- };
39
- type TunnelErrorCodeValue = (typeof TunnelErrorCode)[keyof typeof TunnelErrorCode];
40
- type TunnelCapability = 'filesystem' | 'shell' | 'network' | 'apps' | 'hardware' | 'desktop' | 'gpu';
41
- declare const TunnelMethods: {
42
- readonly 'fs.read': "filesystem";
43
- readonly 'fs.write': "filesystem";
44
- readonly 'fs.list': "filesystem";
45
- readonly 'fs.stat': "filesystem";
46
- readonly 'fs.delete': "filesystem";
47
- readonly 'shell.exec': "shell";
48
- readonly 'desktop.screenshot': "desktop";
49
- readonly 'desktop.mouse.click': "desktop";
50
- readonly 'desktop.mouse.move': "desktop";
51
- readonly 'desktop.mouse.drag': "desktop";
52
- readonly 'desktop.mouse.scroll': "desktop";
53
- readonly 'desktop.mouse.position': "desktop";
54
- readonly 'desktop.keyboard.type': "desktop";
55
- readonly 'desktop.keyboard.key': "desktop";
56
- readonly 'desktop.window.list': "desktop";
57
- readonly 'desktop.window.focus': "desktop";
58
- readonly 'desktop.window.resize': "desktop";
59
- readonly 'desktop.window.close': "desktop";
60
- readonly 'desktop.window.minimize': "desktop";
61
- readonly 'desktop.app.launch': "desktop";
62
- readonly 'desktop.app.quit': "desktop";
63
- readonly 'desktop.app.list': "desktop";
64
- readonly 'desktop.clipboard.read': "desktop";
65
- readonly 'desktop.clipboard.write': "desktop";
66
- readonly 'desktop.screen.info': "desktop";
67
- readonly 'desktop.cursor.image': "desktop";
68
- readonly 'desktop.ax.tree': "desktop";
69
- readonly 'desktop.ax.action': "desktop";
70
- readonly 'desktop.ax.set_value': "desktop";
71
- readonly 'desktop.ax.focus': "desktop";
72
- readonly 'desktop.ax.search': "desktop";
73
- readonly 'net.request': "network";
74
- readonly 'net.port_forward.start': "network";
75
- readonly 'net.port_forward.stop': "network";
76
- readonly 'tunnel.ping': null;
77
- readonly 'tunnel.pong': null;
78
- readonly 'tunnel.permission.revoked': null;
79
- readonly 'tunnel.permissions.sync': null;
80
- readonly 'tunnel.token.rotated': null;
81
- };
82
- type TunnelMethod = keyof typeof TunnelMethods;
83
- interface PendingRPC {
84
- resolve: (value: unknown) => void;
85
- reject: (error: Error) => void;
86
- timer: ReturnType<typeof setTimeout>;
87
- method: string;
88
- tunnelId: string;
89
- startedAt: number;
90
- }
91
- interface SignedJsonRpcRequest extends JsonRpcRequest {
92
- _sig: string;
93
- _nonce: number;
94
- }
95
- interface SignedJsonRpcNotification extends JsonRpcNotification {
96
- _sig: string;
97
- _nonce: number;
98
- }
99
- interface TunnelRpcParams {
100
- capability: TunnelCapability;
101
- operation: string;
102
- args: Record<string, unknown>;
103
- permissionId?: string;
104
- }
105
- interface RelayRpcOptions {
106
- timeoutMs?: number;
107
- }
108
- /** Public agent info — does NOT expose signing key. */
109
- interface AgentInfo {
110
- tunnelId: string;
111
- connectedAt: number;
112
- metadata?: Record<string, unknown>;
113
- }
114
- interface TunnelRelayEvents {
115
- 'agent:connect': {
116
- tunnelId: string;
117
- metadata?: Record<string, unknown>;
118
- };
119
- 'agent:disconnect': {
120
- tunnelId: string;
121
- };
122
- 'agent:timeout': {
123
- tunnelId: string;
124
- };
125
- 'rpc:request': {
126
- tunnelId: string;
127
- method: string;
128
- requestId: string;
129
- };
130
- 'rpc:response': {
131
- tunnelId: string;
132
- method: string;
133
- requestId: string;
134
- durationMs: number;
135
- };
136
- 'rpc:error': {
137
- tunnelId: string;
138
- method: string;
139
- requestId: string;
140
- error: Error;
141
- };
142
- 'connection:replaced': {
143
- tunnelId: string;
144
- };
145
- 'message:pong': {
146
- tunnelId: string;
147
- params?: Record<string, unknown>;
148
- };
149
- 'message:raw': {
150
- tunnelId: string;
151
- message: unknown;
152
- };
153
- }
154
- interface TunnelRelayConfig {
155
- rpcTimeoutMs?: number;
156
- maxWsMessageSize?: number;
157
- }
158
- interface HeartbeatConfig {
159
- intervalMs?: number;
160
- maxMissed?: number;
161
- }
162
- /** Auth handshake message sent by agent as first WS message. */
163
- interface TunnelAuthMessage {
164
- type: 'auth';
165
- token: string;
166
- }
167
- /** Result returned by onAuthenticate hook on success. */
168
- interface AuthResult {
169
- signingKey: string;
170
- metadata?: Record<string, unknown>;
171
- }
172
- interface TunnelServerConfig {
173
- port?: number;
174
- relay?: TunnelRelayConfig;
175
- heartbeat?: HeartbeatConfig;
176
- /**
177
- * Called when an agent sends its auth handshake.
178
- * Return { signingKey, metadata } to accept, or null to reject.
179
- * If not provided, all connections are rejected.
180
- */
181
- onAuthenticate?: (tunnelId: string, token: string) => Promise<AuthResult | null>;
182
- /**
183
- * Called before relaying an RPC to the agent.
184
- * Return false to deny. If not provided, all RPCs are allowed.
185
- */
186
- onAuthorizeRPC?: (tunnelId: string, method: string, params: Record<string, unknown>) => Promise<boolean>;
187
- /**
188
- * Called before handling HTTP requests to relay routes (/connections, /rpc).
189
- * Return false to deny. If not provided, routes are open.
190
- */
191
- onAuthorizeHTTP?: (req: Request) => Promise<boolean>;
192
- }
193
-
194
- export { type AgentInfo as A, type HeartbeatConfig as H, type JsonRpcError as J, type PendingRPC as P, type RelayRpcOptions as R, type SignedJsonRpcNotification as S, type TunnelAuthMessage as T, type AuthResult as a, type JsonRpcErrorResponse as b, type JsonRpcMessage as c, type JsonRpcNotification as d, type JsonRpcRequest as e, type JsonRpcResponse as f, type JsonRpcSuccessResponse as g, type SignedJsonRpcRequest as h, type TunnelCapability as i, TunnelErrorCode as j, type TunnelErrorCodeValue as k, type TunnelMethod as l, TunnelMethods as m, type TunnelRelayConfig as n, type TunnelRelayEvents as o, type TunnelRpcParams as p, type TunnelServerConfig as q };
@@ -1,345 +0,0 @@
1
- import { spawn } from 'child_process';
2
- import { existsSync, mkdirSync, writeFileSync } from 'fs';
3
- import { join } from 'path';
4
- import { homedir } from 'os';
5
-
6
- const HELPER_VERSION = 'v1';
7
- const BIN_DIR = join(homedir(), '.agent-tunnel', 'bin');
8
- const HELPER_PATH = join(BIN_DIR, `atspi-helper-${HELPER_VERSION}.py`);
9
-
10
- const PYTHON_SOURCE = `#!/usr/bin/env python3
11
- """AT-SPI2 accessibility helper for Linux."""
12
- import json
13
- import sys
14
-
15
- try:
16
- import gi
17
- gi.require_version('Atspi', '2.0')
18
- from gi.repository import Atspi
19
- except ImportError:
20
- print(json.dumps({"ok": False, "error": "python3-gi and gir1.2-atspi-2.0 required. Install: sudo apt install python3-gi gir1.2-atspi-2.0"}))
21
- sys.exit(0)
22
-
23
- element_count = 0
24
-
25
- def get_role_name(accessible):
26
- try:
27
- return Atspi.Accessible.get_role_name(accessible)
28
- except:
29
- return ""
30
-
31
- def get_name(accessible):
32
- try:
33
- return Atspi.Accessible.get_name(accessible) or ""
34
- except:
35
- return ""
36
-
37
- def get_description(accessible):
38
- try:
39
- return Atspi.Accessible.get_description(accessible) or ""
40
- except:
41
- return ""
42
-
43
- def get_bounds(accessible):
44
- try:
45
- comp = accessible.get_component_iface()
46
- if comp:
47
- rect = comp.get_extents(Atspi.CoordType.SCREEN)
48
- return {"x": rect.x, "y": rect.y, "width": rect.width, "height": rect.height}
49
- except:
50
- pass
51
- return {"x": 0, "y": 0, "width": 0, "height": 0}
52
-
53
- def get_value(accessible):
54
- try:
55
- val = accessible.get_value_iface()
56
- if val:
57
- return str(val.get_current_value())
58
- except:
59
- pass
60
- return ""
61
-
62
- def get_actions(accessible):
63
- actions = []
64
- try:
65
- action_iface = accessible.get_action_iface()
66
- if action_iface:
67
- for i in range(action_iface.get_n_actions()):
68
- name = action_iface.get_action_name(i)
69
- if name:
70
- actions.append(name)
71
- except:
72
- pass
73
- return actions
74
-
75
- def get_states(accessible):
76
- enabled = True
77
- focused = False
78
- try:
79
- state_set = accessible.get_state_set()
80
- enabled = state_set.contains(Atspi.StateType.ENABLED) or state_set.contains(Atspi.StateType.SENSITIVE)
81
- focused = state_set.contains(Atspi.StateType.FOCUSED)
82
- except:
83
- pass
84
- return enabled, focused
85
-
86
- def walk_tree(accessible, depth, max_depth, roles, path_prefix):
87
- global element_count
88
- if accessible is None or depth > max_depth:
89
- return None
90
- element_count += 1
91
-
92
- role = get_role_name(accessible)
93
- name = get_name(accessible)
94
- value = get_value(accessible)
95
- desc = get_description(accessible)
96
- bounds = get_bounds(accessible)
97
- actions = get_actions(accessible)
98
- enabled, focused = get_states(accessible)
99
-
100
- children = []
101
- if depth < max_depth:
102
- try:
103
- count = accessible.get_child_count()
104
- for i in range(count):
105
- child = accessible.get_child_at_index(i)
106
- if child:
107
- child_path = f"{path_prefix}.{i}" if path_prefix else str(i)
108
- child_node = walk_tree(child, depth + 1, max_depth, roles, child_path)
109
- if child_node is not None:
110
- if isinstance(child_node, list):
111
- children.extend(child_node)
112
- else:
113
- children.append(child_node)
114
- except:
115
- pass
116
-
117
- if roles and role.lower() not in [r.lower() for r in roles]:
118
- return children if children else None
119
-
120
- return {
121
- "id": path_prefix,
122
- "role": role,
123
- "title": name,
124
- "value": value,
125
- "description": desc,
126
- "bounds": bounds,
127
- "children": children,
128
- "actions": actions,
129
- "enabled": enabled,
130
- "focused": focused,
131
- }
132
-
133
- def find_app_by_pid(pid):
134
- desktop = Atspi.get_desktop(0)
135
- count = desktop.get_child_count()
136
- for i in range(count):
137
- app = desktop.get_child_at_index(i)
138
- if app:
139
- try:
140
- if app.get_process_id() == pid:
141
- return app
142
- except:
143
- pass
144
- raise Exception(f"No AT-SPI application found for PID {pid}")
145
-
146
- def navigate_to_element(root, element_id):
147
- parts = element_id.split(".")
148
- current = root
149
- for part in parts:
150
- idx = int(part)
151
- child = current.get_child_at_index(idx)
152
- if child is None:
153
- raise Exception(f"Element not found at path: {element_id}")
154
- current = child
155
- return current
156
-
157
- def search_tree(accessible, query, role_filter, max_results, results, path_prefix, depth, max_depth):
158
- if accessible is None or len(results) >= max_results or depth > max_depth:
159
- return
160
-
161
- role = get_role_name(accessible)
162
- name = get_name(accessible)
163
- value = get_value(accessible)
164
- desc = get_description(accessible)
165
-
166
- query_lower = query.lower()
167
- match = (query_lower in name.lower() or query_lower in value.lower() or query_lower in desc.lower())
168
-
169
- if role_filter and role.lower() != role_filter.lower():
170
- match = False
171
-
172
- if match:
173
- bounds = get_bounds(accessible)
174
- actions = get_actions(accessible)
175
- enabled, focused = get_states(accessible)
176
- results.append({
177
- "id": path_prefix,
178
- "role": role,
179
- "title": name,
180
- "value": value,
181
- "description": desc,
182
- "bounds": bounds,
183
- "children": [],
184
- "actions": actions,
185
- "enabled": enabled,
186
- "focused": focused,
187
- })
188
-
189
- try:
190
- count = accessible.get_child_count()
191
- for i in range(count):
192
- if len(results) >= max_results:
193
- break
194
- child = accessible.get_child_at_index(i)
195
- if child:
196
- child_path = f"{path_prefix}.{i}" if path_prefix else str(i)
197
- search_tree(child, query, role_filter, max_results, results, child_path, depth + 1, max_depth)
198
- except:
199
- pass
200
-
201
- def main():
202
- raw = sys.stdin.read().strip()
203
- try:
204
- req = json.loads(raw)
205
- except:
206
- print(json.dumps({"ok": False, "error": "Invalid JSON input"}))
207
- return
208
-
209
- action = req.get("action", "")
210
-
211
- try:
212
- if action == "ax_tree":
213
- pid = req.get("pid", 0)
214
- max_depth = req.get("maxDepth", 8)
215
- roles = req.get("roles", [])
216
-
217
- root = find_app_by_pid(pid) if pid > 0 else Atspi.get_desktop(0)
218
-
219
- global element_count
220
- element_count = 0
221
- tree = walk_tree(root, 0, max_depth, roles, "0")
222
- print(json.dumps({"ok": True, "root": tree, "elementCount": element_count}))
223
-
224
- elif action == "ax_action":
225
- element_id = req.get("elementId", "")
226
- action_name = req.get("action_name", "")
227
- pid = req.get("pid", 0)
228
-
229
- root = find_app_by_pid(pid) if pid > 0 else Atspi.get_desktop(0)
230
- el = navigate_to_element(root, element_id)
231
-
232
- action_iface = el.get_action_iface()
233
- if not action_iface:
234
- raise Exception("Element does not support actions")
235
-
236
- performed = False
237
- for i in range(action_iface.get_n_actions()):
238
- if action_iface.get_action_name(i).lower() == action_name.lower():
239
- action_iface.do_action(i)
240
- performed = True
241
- break
242
-
243
- if not performed:
244
- raise Exception(f"Action '{action_name}' not found on element")
245
-
246
- print(json.dumps({"ok": True}))
247
-
248
- elif action == "ax_search":
249
- query = req.get("query", "")
250
- role_filter = req.get("role", None)
251
- pid = req.get("pid", 0)
252
- max_results = req.get("maxResults", 20)
253
-
254
- root = find_app_by_pid(pid) if pid > 0 else Atspi.get_desktop(0)
255
- results = []
256
- search_tree(root, query, role_filter, max_results, results, "0", 0, 20)
257
- print(json.dumps({"ok": True, "elements": results}))
258
-
259
- else:
260
- print(json.dumps({"ok": False, "error": f"Unknown action: {action}"}))
261
- except Exception as e:
262
- print(json.dumps({"ok": False, "error": str(e)}))
263
-
264
- if __name__ == "__main__":
265
- main()
266
- `;
267
-
268
- let written = false;
269
-
270
- export async function ensureHelper(): Promise<string> {
271
- if (written && existsSync(HELPER_PATH)) return HELPER_PATH;
272
-
273
- if (existsSync(HELPER_PATH)) {
274
- written = true;
275
- return HELPER_PATH;
276
- }
277
-
278
- mkdirSync(BIN_DIR, { recursive: true });
279
- writeFileSync(HELPER_PATH, PYTHON_SOURCE, { mode: 0o755 });
280
- written = true;
281
-
282
- return HELPER_PATH;
283
- }
284
-
285
- export interface AtspiHelperRequest {
286
- action: string;
287
- pid?: number;
288
- maxDepth?: number;
289
- roles?: string[];
290
- elementId?: string;
291
- action_name?: string;
292
- query?: string;
293
- role?: string;
294
- maxResults?: number;
295
- value?: string;
296
- }
297
-
298
- export interface AtspiHelperResponse {
299
- ok: boolean;
300
- error?: string;
301
- root?: any;
302
- elementCount?: number;
303
- elements?: any[];
304
- }
305
-
306
- export async function execAtspiHelper(request: AtspiHelperRequest): Promise<AtspiHelperResponse> {
307
- const helperPath = await ensureHelper();
308
-
309
- return new Promise((resolve, reject) => {
310
- const proc = spawn('python3', [helperPath], {
311
- stdio: ['pipe', 'pipe', 'pipe'],
312
- });
313
-
314
- let stdout = '';
315
- let stderr = '';
316
-
317
- proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
318
- proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
319
-
320
- proc.on('close', (code) => {
321
- if (code !== 0) {
322
- reject(new Error(`AT-SPI helper failed (exit ${code}): ${stderr}`));
323
- return;
324
- }
325
-
326
- try {
327
- const response = JSON.parse(stdout.trim()) as AtspiHelperResponse;
328
- if (!response.ok && response.error) {
329
- reject(new Error(response.error));
330
- return;
331
- }
332
- resolve(response);
333
- } catch {
334
- reject(new Error(`Invalid helper output: ${stdout}`));
335
- }
336
- });
337
-
338
- proc.on('error', (err) => {
339
- reject(new Error(`python3 not found: ${err.message}. Install: sudo apt install python3`));
340
- });
341
-
342
- proc.stdin.write(JSON.stringify(request));
343
- proc.stdin.end();
344
- });
345
- }