@instaclaw/dispatch 0.2.0 → 0.3.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/dist/auth.d.ts +21 -0
- package/dist/auth.js +64 -0
- package/dist/connection.js +8 -9
- package/dist/executor.js +50 -35
- package/dist/index.js +18 -3
- package/dist/screenshot.d.ts +15 -0
- package/dist/screenshot.js +47 -0
- package/dist/supervisor.js +9 -2
- package/dist/types.d.ts +6 -0
- package/package.json +6 -4
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate auth params for WebSocket handshake (client-side).
|
|
3
|
+
*/
|
|
4
|
+
export declare function generateHandshake(gatewayToken: string): {
|
|
5
|
+
hmac: string;
|
|
6
|
+
ts: string;
|
|
7
|
+
nonce: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Build the WebSocket URL with auth query params.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildAuthUrl(vmAddress: string, port: number, gatewayToken: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Verify auth params from WebSocket handshake (server-side).
|
|
15
|
+
* Returns null on success, error string on failure.
|
|
16
|
+
*/
|
|
17
|
+
export declare function verifyHandshake(hmac: string, ts: string, nonce: string, gatewayToken: string, seenNonces: Map<string, number>): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Clean up expired nonces (call periodically).
|
|
20
|
+
*/
|
|
21
|
+
export declare function cleanupNonces(seenNonces: Map<string, number>, maxAgeMs?: number): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth — HMAC-based authentication for the dispatch WebSocket handshake.
|
|
3
|
+
*
|
|
4
|
+
* Protocol:
|
|
5
|
+
* Client sends: ?hmac=<hex>&ts=<unix_ms>&nonce=<random_hex>
|
|
6
|
+
* Server computes: HMAC-SHA256(gateway_token, ts + ":" + nonce)
|
|
7
|
+
* Server verifies: hmac matches, ts within 30s, nonce not seen before
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
const HANDSHAKE_MAX_AGE_MS = 30_000; // 30 seconds
|
|
11
|
+
/**
|
|
12
|
+
* Generate auth params for WebSocket handshake (client-side).
|
|
13
|
+
*/
|
|
14
|
+
export function generateHandshake(gatewayToken) {
|
|
15
|
+
const ts = String(Date.now());
|
|
16
|
+
const nonce = crypto.randomBytes(16).toString("hex");
|
|
17
|
+
const hmac = crypto.createHmac("sha256", gatewayToken)
|
|
18
|
+
.update(ts + ":" + nonce)
|
|
19
|
+
.digest("hex");
|
|
20
|
+
return { hmac, ts, nonce };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the WebSocket URL with auth query params.
|
|
24
|
+
*/
|
|
25
|
+
export function buildAuthUrl(vmAddress, port, gatewayToken) {
|
|
26
|
+
const { hmac, ts, nonce } = generateHandshake(gatewayToken);
|
|
27
|
+
return `wss://${vmAddress}:${port}?hmac=${hmac}&ts=${ts}&nonce=${nonce}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Verify auth params from WebSocket handshake (server-side).
|
|
31
|
+
* Returns null on success, error string on failure.
|
|
32
|
+
*/
|
|
33
|
+
export function verifyHandshake(hmac, ts, nonce, gatewayToken, seenNonces) {
|
|
34
|
+
// Timestamp check
|
|
35
|
+
const tsMs = parseInt(ts, 10);
|
|
36
|
+
if (isNaN(tsMs) || Math.abs(Date.now() - tsMs) > HANDSHAKE_MAX_AGE_MS) {
|
|
37
|
+
return "Handshake expired (timestamp > 30s old)";
|
|
38
|
+
}
|
|
39
|
+
// Nonce replay check
|
|
40
|
+
if (seenNonces.has(nonce)) {
|
|
41
|
+
return "Nonce already used (replay attack)";
|
|
42
|
+
}
|
|
43
|
+
// HMAC verification
|
|
44
|
+
const expected = crypto.createHmac("sha256", gatewayToken)
|
|
45
|
+
.update(ts + ":" + nonce)
|
|
46
|
+
.digest("hex");
|
|
47
|
+
if (!crypto.timingSafeEqual(Buffer.from(hmac, "hex"), Buffer.from(expected, "hex"))) {
|
|
48
|
+
return "HMAC verification failed";
|
|
49
|
+
}
|
|
50
|
+
// Record nonce (clean up old ones)
|
|
51
|
+
seenNonces.set(nonce, Date.now());
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Clean up expired nonces (call periodically).
|
|
56
|
+
*/
|
|
57
|
+
export function cleanupNonces(seenNonces, maxAgeMs = 60_000) {
|
|
58
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
59
|
+
for (const [nonce, ts] of seenNonces) {
|
|
60
|
+
if (ts < cutoff)
|
|
61
|
+
seenNonces.delete(nonce);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/connection.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
import WebSocket from "ws";
|
|
6
6
|
import { executeCommand } from "./executor.js";
|
|
7
7
|
import { requestApproval } from "./supervisor.js";
|
|
8
|
+
import { buildAuthUrl } from "./auth.js";
|
|
8
9
|
let ws = null;
|
|
9
10
|
let reconnectDelay = 1000;
|
|
10
11
|
let shouldReconnect = true;
|
|
11
12
|
let commandsExecuted = 0;
|
|
12
13
|
export function connect(opts) {
|
|
13
|
-
|
|
14
|
-
const url =
|
|
14
|
+
// HMAC-authenticated URL (token + timestamp + nonce)
|
|
15
|
+
const url = buildAuthUrl(opts.vmAddress, opts.port, opts.gatewayToken);
|
|
15
16
|
console.log(` Connecting to ${opts.vmAddress}:${opts.port}...`);
|
|
16
17
|
ws = new WebSocket(url, {
|
|
17
18
|
rejectUnauthorized: false, // Accept self-signed certs (TOFU model)
|
|
@@ -26,13 +27,11 @@ export function connect(opts) {
|
|
|
26
27
|
return; // We don't expect binary from the server
|
|
27
28
|
try {
|
|
28
29
|
const command = JSON.parse(data.toString());
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
30
|
+
// Check approval (supervisor handles mode logic — prompts in supervised, auto-approves in autonomous, blocks dangerous)
|
|
31
|
+
const approved = await requestApproval(command);
|
|
32
|
+
if (!approved) {
|
|
33
|
+
sendResult(command.id, { success: false, error: "User denied the action" });
|
|
34
|
+
return;
|
|
36
35
|
}
|
|
37
36
|
// Execute the command
|
|
38
37
|
const result = await executeCommand(command);
|
package/dist/executor.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* Returns results (action confirmations or screenshot data).
|
|
4
4
|
*/
|
|
5
5
|
import { execSync } from "child_process";
|
|
6
|
-
import
|
|
7
|
-
import path from "path";
|
|
6
|
+
import { captureScreenshot } from "./screenshot.js";
|
|
8
7
|
// Dynamic import for usecomputer (ESM)
|
|
9
8
|
let uc = null;
|
|
9
|
+
// Track the most recent coordMap from screenshots for click mapping
|
|
10
|
+
let lastCoordMap = null;
|
|
10
11
|
async function getUC() {
|
|
11
12
|
if (!uc) {
|
|
12
13
|
uc = await import("usecomputer");
|
|
@@ -27,6 +28,8 @@ export async function executeCommand(command) {
|
|
|
27
28
|
return await doPress(params);
|
|
28
29
|
case "scroll":
|
|
29
30
|
return await doScroll(params);
|
|
31
|
+
case "drag":
|
|
32
|
+
return await doDrag(params);
|
|
30
33
|
case "windows":
|
|
31
34
|
return await listWindows();
|
|
32
35
|
default:
|
|
@@ -38,45 +41,41 @@ export async function executeCommand(command) {
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
async function takeScreenshot(params) {
|
|
44
|
+
const capture = await captureScreenshot({
|
|
45
|
+
format: params.format || "jpeg",
|
|
46
|
+
quality: Number(params.quality) || 80,
|
|
47
|
+
});
|
|
48
|
+
// Cache coordMap for subsequent click/hover/drag commands
|
|
49
|
+
lastCoordMap = capture.coordMap;
|
|
50
|
+
return {
|
|
51
|
+
success: true,
|
|
52
|
+
screenshotBuffer: capture.buffer,
|
|
53
|
+
screenshotMeta: {
|
|
54
|
+
width: capture.width,
|
|
55
|
+
height: capture.height,
|
|
56
|
+
format: capture.format,
|
|
57
|
+
coordMap: capture.coordMap,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function doClick(params) {
|
|
41
62
|
const usecomputer = await getUC();
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
const x = Number(params.x);
|
|
64
|
+
const y = Number(params.y);
|
|
65
|
+
const coordMap = params.coordMap ? String(params.coordMap) : lastCoordMap;
|
|
66
|
+
let point = { x, y };
|
|
67
|
+
if (coordMap) {
|
|
68
|
+
// Map screenshot-space coordinates to desktop-space (critical for Retina/HiDPI)
|
|
48
69
|
try {
|
|
49
|
-
const
|
|
50
|
-
|
|
70
|
+
const parsed = usecomputer.parseCoordMapOrThrow(coordMap);
|
|
71
|
+
point = usecomputer.mapPointFromCoordMap({ point: { x, y }, coordMap: parsed });
|
|
51
72
|
}
|
|
52
73
|
catch {
|
|
53
|
-
//
|
|
54
|
-
jpegBuffer = pngBuffer;
|
|
74
|
+
// If mapping fails, fall back to raw coords
|
|
55
75
|
}
|
|
56
|
-
return {
|
|
57
|
-
success: true,
|
|
58
|
-
screenshotBuffer: jpegBuffer,
|
|
59
|
-
screenshotMeta: {
|
|
60
|
-
width: result.imageWidth,
|
|
61
|
-
height: result.imageHeight,
|
|
62
|
-
format: "jpeg",
|
|
63
|
-
coordMap: result.coordMap,
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
76
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
fs.unlinkSync(tmpPath);
|
|
70
|
-
}
|
|
71
|
-
catch { }
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
async function doClick(params) {
|
|
75
|
-
const usecomputer = await getUC();
|
|
76
|
-
const x = Number(params.x);
|
|
77
|
-
const y = Number(params.y);
|
|
78
|
-
await usecomputer.click({ point: { x, y }, button: "left", count: 1 });
|
|
79
|
-
return { success: true, data: { action: "click", x, y } };
|
|
77
|
+
await usecomputer.click({ point, button: "left", count: 1 });
|
|
78
|
+
return { success: true, data: { action: "click", x, y, mappedX: point.x, mappedY: point.y, coordMap: coordMap || "none" } };
|
|
80
79
|
}
|
|
81
80
|
async function doType(params) {
|
|
82
81
|
const usecomputer = await getUC();
|
|
@@ -108,6 +107,22 @@ async function doScroll(params) {
|
|
|
108
107
|
await usecomputer.scroll({ direction, amount, at: null });
|
|
109
108
|
return { success: true, data: { action: "scroll", direction, amount } };
|
|
110
109
|
}
|
|
110
|
+
async function doDrag(params) {
|
|
111
|
+
const usecomputer = await getUC();
|
|
112
|
+
const coordMap = params.coordMap ? String(params.coordMap) : lastCoordMap;
|
|
113
|
+
let from = { x: Number(params.fromX), y: Number(params.fromY) };
|
|
114
|
+
let to = { x: Number(params.toX), y: Number(params.toY) };
|
|
115
|
+
if (coordMap) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = usecomputer.parseCoordMapOrThrow(coordMap);
|
|
118
|
+
from = usecomputer.mapPointFromCoordMap({ point: from, coordMap: parsed });
|
|
119
|
+
to = usecomputer.mapPointFromCoordMap({ point: to, coordMap: parsed });
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
}
|
|
123
|
+
await usecomputer.drag({ from, to, durationMs: null, button: null });
|
|
124
|
+
return { success: true, data: { action: "drag" } };
|
|
125
|
+
}
|
|
111
126
|
async function listWindows() {
|
|
112
127
|
const usecomputer = await getUC();
|
|
113
128
|
const windows = await usecomputer.windowList();
|
package/dist/index.js
CHANGED
|
@@ -10,12 +10,13 @@ import { loadConfig, saveConfig, getConfigPath } from "./config.js";
|
|
|
10
10
|
import { checkPermissions, printPermissionGuide, runPermissionTest } from "./permissions.js";
|
|
11
11
|
import { connect, disconnect } from "./connection.js";
|
|
12
12
|
import { closeSupervisor, setMode } from "./supervisor.js";
|
|
13
|
+
import chalk from "chalk";
|
|
13
14
|
import readline from "readline";
|
|
14
15
|
import os from "os";
|
|
15
16
|
async function main() {
|
|
16
17
|
console.log(`
|
|
17
|
-
InstaClaw Dispatch — Remote Computer Control
|
|
18
|
-
─────────────────────────────────────────────
|
|
18
|
+
${chalk.bold("InstaClaw Dispatch")} ${chalk.dim("— Remote Computer Control")}
|
|
19
|
+
${chalk.dim("─────────────────────────────────────────────")}
|
|
19
20
|
`);
|
|
20
21
|
// 1. Check permissions (macOS only)
|
|
21
22
|
if (os.platform() === "darwin") {
|
|
@@ -89,7 +90,21 @@ async function main() {
|
|
|
89
90
|
console.log(`\n Dispatch mode: SUPERVISED`);
|
|
90
91
|
console.log(` Screenshots auto-approved. Actions require [Enter] to confirm.`);
|
|
91
92
|
}
|
|
92
|
-
console.log(` Press Ctrl+C to disconnect.\n`);
|
|
93
|
+
console.log(` Press 'a' to toggle autonomous mode. Press Ctrl+C to disconnect.\n`);
|
|
94
|
+
// Runtime mode switching via keypress
|
|
95
|
+
if (process.stdin.isTTY) {
|
|
96
|
+
process.stdin.setRawMode?.(false); // Ensure line mode for supervisor readline
|
|
97
|
+
process.stdin.on("data", (chunk) => {
|
|
98
|
+
const key = chunk.toString().trim().toLowerCase();
|
|
99
|
+
if (key === "a") {
|
|
100
|
+
const newMode = config.mode === "supervised" ? "autonomous" : "supervised";
|
|
101
|
+
config.mode = newMode;
|
|
102
|
+
setMode(newMode);
|
|
103
|
+
saveConfig(config);
|
|
104
|
+
console.log(`\n Mode switched to: ${newMode.toUpperCase()}\n`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
93
108
|
},
|
|
94
109
|
onDisconnect: () => {
|
|
95
110
|
// Will auto-reconnect
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ScreenshotOptions {
|
|
2
|
+
format?: "jpeg" | "png";
|
|
3
|
+
quality?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface ScreenshotCapture {
|
|
6
|
+
buffer: Buffer;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
format: string;
|
|
10
|
+
coordMap: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Capture a screenshot and return it as a buffer with metadata.
|
|
14
|
+
*/
|
|
15
|
+
export declare function captureScreenshot(opts?: ScreenshotOptions): Promise<ScreenshotCapture>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot — captures screen via usecomputer, converts PNG→JPEG via sharp.
|
|
3
|
+
* Separated per PRD Section 5.1 package structure.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
/**
|
|
8
|
+
* Capture a screenshot and return it as a buffer with metadata.
|
|
9
|
+
*/
|
|
10
|
+
export async function captureScreenshot(opts = {}) {
|
|
11
|
+
const uc = await import("usecomputer");
|
|
12
|
+
const tmpPath = path.join("/tmp", `dispatch-ss-${Date.now()}.png`);
|
|
13
|
+
const format = opts.format || "jpeg";
|
|
14
|
+
const quality = opts.quality || 80;
|
|
15
|
+
try {
|
|
16
|
+
const result = await uc.screenshot({ path: tmpPath, display: null, window: null, region: null, annotate: null });
|
|
17
|
+
const pngBuffer = fs.readFileSync(tmpPath);
|
|
18
|
+
let outputBuffer;
|
|
19
|
+
if (format === "jpeg") {
|
|
20
|
+
try {
|
|
21
|
+
const sharp = (await import("sharp")).default;
|
|
22
|
+
outputBuffer = await sharp(pngBuffer).jpeg({ quality }).toBuffer();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// sharp not available — fall back to PNG
|
|
26
|
+
outputBuffer = pngBuffer;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
outputBuffer = pngBuffer;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
buffer: outputBuffer,
|
|
34
|
+
width: result.imageWidth,
|
|
35
|
+
height: result.imageHeight,
|
|
36
|
+
format,
|
|
37
|
+
coordMap: result.coordMap,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(tmpPath);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=screenshot.js.map
|
package/dist/supervisor.js
CHANGED
|
@@ -9,15 +9,22 @@ import readline from "readline";
|
|
|
9
9
|
const AUTO_APPROVE_TYPES = new Set(["screenshot", "windows", "status"]);
|
|
10
10
|
// Patterns that indicate dangerous actions — always require confirmation even in autonomous mode
|
|
11
11
|
const DANGEROUS_TEXT_PATTERNS = [
|
|
12
|
+
// Authentication
|
|
12
13
|
/password/i, /passwd/i, /secret/i, /credential/i,
|
|
13
|
-
/
|
|
14
|
+
/\b2fa\b/i, /\bmfa\b/i, /authenticat/i, /verification.?code/i, /one.?time.?pass/i,
|
|
15
|
+
// Destructive
|
|
16
|
+
/delete/i, /remove/i, /destroy/i, /format/i, /erase/i, /empty.?trash/i,
|
|
17
|
+
// Financial
|
|
14
18
|
/purchase/i, /submit.*order/i, /confirm.*payment/i, /buy\s/i,
|
|
15
19
|
/transfer.*fund/i, /send.*money/i, /withdraw/i,
|
|
16
|
-
|
|
20
|
+
// System
|
|
21
|
+
/sudo\s/i, /rm\s+-rf/i, /shutdown/i, /reboot/i, /restart/i,
|
|
22
|
+
/\binstall\b/i, /\bapt\b/i, /\bbrew\b/i, /\bnpm\s+i/i, /pip\s+install/i,
|
|
17
23
|
];
|
|
18
24
|
const DANGEROUS_KEY_COMBOS = new Set([
|
|
19
25
|
"cmd+delete", "cmd+shift+delete", "ctrl+delete",
|
|
20
26
|
"cmd+q", "alt+f4",
|
|
27
|
+
"cmd+shift+backspace", // Empty Trash on macOS
|
|
21
28
|
]);
|
|
22
29
|
let mode = "supervised";
|
|
23
30
|
const isTTY = process.stdin.isTTY ?? false;
|
package/dist/types.d.ts
CHANGED
|
@@ -31,4 +31,10 @@ export interface DispatchConfig {
|
|
|
31
31
|
mode: "supervised" | "autonomous";
|
|
32
32
|
/** TLS cert fingerprint for TOFU (trust on first use) */
|
|
33
33
|
certFingerprint?: string;
|
|
34
|
+
/** Screenshot format: "jpeg" or "png" */
|
|
35
|
+
screenshotFormat?: "jpeg" | "png";
|
|
36
|
+
/** Screenshot quality (1-100, JPEG only) */
|
|
37
|
+
screenshotQuality?: number;
|
|
38
|
+
/** Max actions per minute before rate limiting */
|
|
39
|
+
maxActionsPerMinute?: number;
|
|
34
40
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@instaclaw/dispatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Let your InstaClaw AI agent control your computer — screenshots, clicks, typing, and more via a secure WebSocket relay",
|
|
5
5
|
"bin": {
|
|
6
6
|
"instaclaw-dispatch": "./dist/index.js"
|
|
@@ -30,13 +30,15 @@
|
|
|
30
30
|
},
|
|
31
31
|
"homepage": "https://instaclaw.io",
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"chalk": "^5.6.2",
|
|
34
|
+
"inquirer": "^9.3.8",
|
|
35
|
+
"sharp": "^0.33.0",
|
|
33
36
|
"usecomputer": "^0.1.4",
|
|
34
|
-
"ws": "^8.18.0"
|
|
35
|
-
"sharp": "^0.33.0"
|
|
37
|
+
"ws": "^8.18.0"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
|
-
"@types/ws": "^8.5.0",
|
|
39
40
|
"@types/node": "^22.0.0",
|
|
41
|
+
"@types/ws": "^8.5.0",
|
|
40
42
|
"typescript": "^5.7.0"
|
|
41
43
|
},
|
|
42
44
|
"engines": {
|