@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 +37 -0
- package/src/agent/agent.ts +331 -0
- package/src/agent/capabilities/desktop/atspi-helper.ts +345 -0
- package/src/agent/capabilities/desktop/csharp-helper.ts +914 -0
- package/src/agent/capabilities/desktop/linux-driver.ts +368 -0
- package/src/agent/capabilities/desktop/macos-driver.ts +601 -0
- package/src/agent/capabilities/desktop/swift-helper.ts +736 -0
- package/src/agent/capabilities/desktop/types.ts +201 -0
- package/src/agent/capabilities/desktop/windows-driver.ts +220 -0
- package/src/agent/capabilities/desktop.ts +196 -0
- package/src/agent/capabilities/filesystem.ts +133 -0
- package/src/agent/capabilities/index.ts +42 -0
- package/src/agent/capabilities/shell.ts +96 -0
- package/src/agent/cli.ts +222 -0
- package/src/agent/config.ts +54 -0
- package/src/agent/index.ts +11 -0
- package/src/agent/security/command-validator.ts +61 -0
- package/src/agent/security/path-validator.ts +55 -0
- package/src/agent/security/permission-guard.ts +66 -0
- package/src/client/index.ts +4 -0
- package/src/client/tools.ts +603 -0
- package/src/client/tunnel-client.ts +270 -0
- package/src/index.ts +62 -0
- package/src/server/heartbeat.ts +84 -0
- package/src/server/index.ts +7 -0
- package/src/server/relay.ts +266 -0
- package/src/server/routes.ts +61 -0
- package/src/server/server.ts +114 -0
- package/src/server/ws-handler.ts +54 -0
- package/src/shared/crypto.ts +58 -0
- package/src/shared/index.ts +33 -0
- package/src/shared/types.ts +164 -0
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
|
+
}
|