@kortix/agent-tunnel 0.1.0

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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kortix/agent-tunnel",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Tunnel relay between cloud AI agents and local machines — server relay, local agent, client SDK, JSON-RPC, HMAC signing",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./shared": "./src/shared/index.ts",
12
+ "./server": "./src/server/index.ts",
13
+ "./client": "./src/client/index.ts",
14
+ "./agent": "./src/agent/index.ts"
15
+ },
16
+ "bin": {
17
+ "agent-tunnel": "src/agent/cli.ts"
18
+ },
19
+ "files": [
20
+ "src/"
21
+ ],
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "hono": "^4.4.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.3.9",
30
+ "typescript": "^5.4.0"
31
+ },
32
+ "keywords": ["tunnel", "relay", "websocket", "json-rpc", "hmac", "agent", "sdk"],
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
@@ -0,0 +1,331 @@
1
+ import { hostname, platform, arch, release } from 'os';
2
+ import type { TunnelConfig } from './config';
3
+ import { CapabilityRegistry, type RpcHandler } from './capabilities/index';
4
+ import { PermissionGuard } from './security/permission-guard';
5
+ import type { LocalPermission } from './security/permission-guard';
6
+ import { deriveSigningKey, verifyMessageSignature } from '../shared/crypto';
7
+
8
+ interface JsonRpcRequest {
9
+ jsonrpc: '2.0';
10
+ id: string;
11
+ method: string;
12
+ params?: Record<string, unknown>;
13
+ _sig?: string;
14
+ _nonce?: number;
15
+ }
16
+
17
+ interface JsonRpcNotification {
18
+ jsonrpc: '2.0';
19
+ method: string;
20
+ params?: Record<string, unknown>;
21
+ _sig?: string;
22
+ _nonce?: number;
23
+ }
24
+
25
+ type IncomingMessage = JsonRpcRequest | JsonRpcNotification;
26
+
27
+ const c = {
28
+ reset: '\x1b[0m',
29
+ bold: '\x1b[1m',
30
+ dim: '\x1b[2m',
31
+ cyan: '\x1b[36m',
32
+ green: '\x1b[32m',
33
+ yellow: '\x1b[33m',
34
+ red: '\x1b[31m',
35
+ white: '\x1b[97m',
36
+ gray: '\x1b[90m',
37
+ };
38
+
39
+ function log(icon: string, msg: string) {
40
+ console.log(` ${icon} ${c.dim}${msg}${c.reset}`);
41
+ }
42
+
43
+ export class TunnelAgent {
44
+ private ws: WebSocket | null = null;
45
+ private registry: CapabilityRegistry;
46
+ private permissionGuard: PermissionGuard;
47
+ private config: TunnelConfig;
48
+ private reconnectAttempts = 0;
49
+ private maxReconnectDelay = 30_000;
50
+ private baseReconnectDelay = 1_000;
51
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
52
+ private isShuttingDown = false;
53
+ private uptime = 0;
54
+ private uptimeInterval: ReturnType<typeof setInterval> | null = null;
55
+
56
+ // HMAC signature verification
57
+ private signingKey: string;
58
+ private lastNonce = 0;
59
+
60
+ constructor(config: TunnelConfig, registry: CapabilityRegistry) {
61
+ this.config = config;
62
+ this.registry = registry;
63
+ this.permissionGuard = new PermissionGuard();
64
+ this.signingKey = deriveSigningKey(config.token);
65
+ }
66
+
67
+ connect(): void {
68
+ if (this.ws) {
69
+ this.ws.close();
70
+ }
71
+
72
+ const wsUrl = this.buildWsUrl();
73
+ log(`${c.cyan}◆${c.reset}`, `Connecting…`);
74
+
75
+ try {
76
+ this.ws = new WebSocket(wsUrl);
77
+ this.setupWsHandlers();
78
+ } catch (err) {
79
+ log(`${c.red}✗${c.reset}`, `Connection failed`);
80
+ this.scheduleReconnect();
81
+ }
82
+ }
83
+
84
+ disconnect(): void {
85
+ this.isShuttingDown = true;
86
+
87
+ if (this.reconnectTimer) {
88
+ clearTimeout(this.reconnectTimer);
89
+ this.reconnectTimer = null;
90
+ }
91
+
92
+ if (this.uptimeInterval) {
93
+ clearInterval(this.uptimeInterval);
94
+ this.uptimeInterval = null;
95
+ }
96
+
97
+ if (this.ws) {
98
+ try { this.ws.close(1000, 'client shutdown'); } catch {}
99
+ this.ws = null;
100
+ }
101
+
102
+ this.permissionGuard.clear();
103
+ log(`${c.gray}○${c.reset}`, `Disconnected`);
104
+ }
105
+
106
+ isConnected(): boolean {
107
+ return this.ws?.readyState === WebSocket.OPEN;
108
+ }
109
+
110
+ private setupWsHandlers(): void {
111
+ if (!this.ws) return;
112
+
113
+ this.ws.addEventListener('open', () => {
114
+ this.reconnectAttempts = 0;
115
+ this.uptime = 0;
116
+ this.lastNonce = 0; // Reset nonce on each new connection
117
+ this.uptimeInterval = setInterval(() => { this.uptime++; }, 1000);
118
+
119
+ log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${this.registry.getCapabilityNames().join(', ')})${c.reset}`);
120
+ });
121
+
122
+ this.ws.addEventListener('message', (event) => {
123
+ this.handleMessage(event.data as string);
124
+ });
125
+
126
+ this.ws.addEventListener('close', (event) => {
127
+ if (this.uptimeInterval) {
128
+ clearInterval(this.uptimeInterval);
129
+ this.uptimeInterval = null;
130
+ }
131
+
132
+ if (!this.isShuttingDown) {
133
+ log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
134
+ this.scheduleReconnect();
135
+ }
136
+ });
137
+
138
+ this.ws.addEventListener('error', (event) => {
139
+ log(`${c.red}✗${c.reset}`, `WebSocket error`);
140
+ });
141
+ }
142
+
143
+ private async handleMessage(raw: string): Promise<void> {
144
+ let msg: IncomingMessage;
145
+ try {
146
+ msg = JSON.parse(raw);
147
+ } catch {
148
+ log(`${c.yellow}!${c.reset}`, `Received invalid JSON`);
149
+ return;
150
+ }
151
+
152
+ // ── Heartbeat ping — no signature required ──────────────────────
153
+ if ('method' in msg && msg.method === 'tunnel.ping') {
154
+ this.sendPong();
155
+ return;
156
+ }
157
+
158
+ // ── Verify HMAC signature on all other messages ─────────────────
159
+ if (!this.verifyIncomingSignature(msg, raw)) {
160
+ if ('id' in msg && msg.id) {
161
+ this.sendError(msg.id, -32000, 'Invalid message signature');
162
+ }
163
+ return;
164
+ }
165
+
166
+ // ── Permission sync notification ────────────────────────────────
167
+ if ('method' in msg && msg.method === 'tunnel.permissions.sync') {
168
+ const permissions = (msg.params?.permissions || []) as LocalPermission[];
169
+ this.permissionGuard.syncPermissions(permissions);
170
+ log(`${c.green}●${c.reset}`, `Synced ${c.reset}${c.white}${permissions.length}${c.dim} permissions`);
171
+ return;
172
+ }
173
+
174
+ // ── Permission granted notification ────────────────────────────
175
+ if ('method' in msg && msg.method === 'tunnel.permission.granted') {
176
+ const p = msg.params as LocalPermission | undefined;
177
+ if (p?.permissionId) {
178
+ this.permissionGuard.addPermission(p);
179
+ log(`${c.green}+${c.reset}`, `Permission granted: ${p.capability} (${p.permissionId.slice(0, 12)}…)`);
180
+ }
181
+ return;
182
+ }
183
+
184
+ // ── Permission revocation notification ──────────────────────────
185
+ if ('method' in msg && msg.method === 'tunnel.permission.revoked') {
186
+ const permissionId = msg.params?.permissionId as string;
187
+ if (permissionId) {
188
+ this.permissionGuard.revokePermission(permissionId);
189
+ log(`${c.yellow}○${c.reset}`, `Permission revoked: ${permissionId.slice(0, 12)}…`);
190
+ }
191
+ return;
192
+ }
193
+
194
+ // ── Token rotation notification ─────────────────────────────────
195
+ if ('method' in msg && msg.method === 'tunnel.token.rotated') {
196
+ log(`${c.yellow}!${c.reset}`, `Token rotated — reconnecting with new token`);
197
+ // The server will close the WS shortly; the reconnect logic handles the rest.
198
+ // Caller must update config.token before reconnect succeeds.
199
+ return;
200
+ }
201
+
202
+ // ── RPC request dispatch ────────────────────────────────────────
203
+ if ('id' in msg && msg.id) {
204
+ await this.handleRpcRequest(msg as JsonRpcRequest);
205
+ return;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Verify HMAC signature on incoming messages (excluding pings).
211
+ * Returns true if valid, false if signature check fails.
212
+ */
213
+ private verifyIncomingSignature(msg: IncomingMessage, _raw: string): boolean {
214
+ const sig = (msg as any)._sig as string | undefined;
215
+ const nonce = (msg as any)._nonce as number | undefined;
216
+
217
+ if (sig === undefined || nonce === undefined) {
218
+ log(`${c.yellow}!${c.reset}`, `Message missing signature fields`);
219
+ return false;
220
+ }
221
+
222
+ // Replay protection: nonce must be strictly increasing
223
+ if (nonce <= this.lastNonce) {
224
+ log(`${c.red}✗${c.reset}`, `Replay detected: nonce ${nonce} <= ${this.lastNonce}`);
225
+ return false;
226
+ }
227
+
228
+ // Build the payload to verify (message without _sig and _nonce)
229
+ const { _sig, _nonce, ...payloadObj } = msg as any;
230
+ const payload = JSON.stringify(payloadObj);
231
+
232
+ if (!verifyMessageSignature(this.signingKey, payload, nonce, sig)) {
233
+ log(`${c.red}✗${c.reset}`, `Invalid HMAC signature`);
234
+ return false;
235
+ }
236
+
237
+ this.lastNonce = nonce;
238
+ return true;
239
+ }
240
+
241
+ private async handleRpcRequest(request: JsonRpcRequest): Promise<void> {
242
+ const { id, method, params = {} } = request;
243
+
244
+ // ── Permission enforcement (defense-in-depth) ───────────────────
245
+ const permissionId = params.permissionId as string | undefined;
246
+ if (!this.permissionGuard.checkPermission(permissionId)) {
247
+ this.sendError(id, -32000, `Permission denied: ${permissionId ? 'invalid or expired permission' : 'no permissionId provided'}`);
248
+ return;
249
+ }
250
+
251
+ const handler = this.registry.getHandler(method);
252
+ if (!handler) {
253
+ this.sendError(id, -32001, `Capability not registered for method: ${method}`);
254
+ return;
255
+ }
256
+
257
+ try {
258
+ const result = await handler(params);
259
+ this.sendResult(id, result);
260
+ } catch (err) {
261
+ const message = err instanceof Error ? err.message : String(err);
262
+ this.sendError(id, -32003, message);
263
+ }
264
+ }
265
+
266
+ private sendResult(id: string, result: unknown): void {
267
+ this.send({ jsonrpc: '2.0', id, result });
268
+ }
269
+
270
+ private sendError(id: string, code: number, message: string): void {
271
+ this.send({ jsonrpc: '2.0', id, error: { code, message } });
272
+ }
273
+
274
+ private sendPong(): void {
275
+ this.send({
276
+ jsonrpc: '2.0',
277
+ method: 'tunnel.pong',
278
+ params: {
279
+ uptime: this.uptime,
280
+ capabilities: this.registry.getCapabilityNames(),
281
+ machineInfo: {
282
+ hostname: hostname(),
283
+ platform: platform(),
284
+ arch: arch(),
285
+ osVersion: release(),
286
+ agentVersion: '0.1.0',
287
+ },
288
+ },
289
+ });
290
+ }
291
+
292
+ private send(data: unknown): void {
293
+ if (this.ws?.readyState === WebSocket.OPEN) {
294
+ try {
295
+ this.ws.send(JSON.stringify(data));
296
+ } catch (err) {
297
+ log(`${c.red}✗${c.reset}`, `Send failed`);
298
+ }
299
+ }
300
+ }
301
+
302
+ private scheduleReconnect(): void {
303
+ if (this.isShuttingDown) return;
304
+
305
+ this.reconnectAttempts++;
306
+ const delay = Math.min(
307
+ this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
308
+ this.maxReconnectDelay,
309
+ );
310
+
311
+ log(`${c.cyan}◆${c.reset}`, `Reconnecting in ${c.reset}${c.white}${(delay / 1000).toFixed(1)}s${c.dim} (attempt ${this.reconnectAttempts})`);
312
+
313
+ this.reconnectTimer = setTimeout(() => {
314
+ this.connect();
315
+ }, delay);
316
+ }
317
+
318
+ private buildWsUrl(): string {
319
+ const base = this.config.apiUrl
320
+ .replace(/^http:/, 'ws:')
321
+ .replace(/^https:/, 'wss:');
322
+
323
+ const wsPath = this.config.wsPath || '/ws';
324
+ const params = new URLSearchParams({
325
+ token: this.config.token,
326
+ tunnelId: this.config.tunnelId,
327
+ });
328
+
329
+ return `${base}${wsPath}?${params.toString()}`;
330
+ }
331
+ }
@@ -0,0 +1,345 @@
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(), '.kortix-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
+ }