@instaclaw/dispatch 0.2.0 → 0.3.1

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 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
@@ -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
- const proto = "wss";
14
- const url = `${proto}://${opts.vmAddress}:${opts.port}?token=${opts.gatewayToken}`;
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
- // Supervised mode: ask user for approval
30
- if (opts.mode === "supervised") {
31
- const approved = await requestApproval(command);
32
- if (!approved) {
33
- sendResult(command.id, { success: false, error: "User denied the action" });
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 fs from "fs";
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,44 @@ 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 tmpPath = path.join("/tmp", `dispatch-ss-${Date.now()}.png`);
43
- try {
44
- const result = await usecomputer.screenshot({ path: tmpPath, display: null, window: null, region: null, annotate: null });
45
- // Read the PNG and convert to JPEG via sharp
46
- const pngBuffer = fs.readFileSync(tmpPath);
47
- let jpegBuffer;
63
+ const x = Number(params.x);
64
+ const y = Number(params.y);
65
+ if (isNaN(x) || isNaN(y) || x < 0 || y < 0) {
66
+ return { success: false, error: `Invalid coordinates: (${params.x}, ${params.y}). Must be non-negative numbers.` };
67
+ }
68
+ const coordMap = params.coordMap ? String(params.coordMap) : lastCoordMap;
69
+ let point = { x, y };
70
+ if (coordMap) {
71
+ // Map screenshot-space coordinates to desktop-space (critical for Retina/HiDPI)
48
72
  try {
49
- const sharp = (await import("sharp")).default;
50
- jpegBuffer = await sharp(pngBuffer).jpeg({ quality: 80 }).toBuffer();
73
+ const parsed = usecomputer.parseCoordMapOrThrow(coordMap);
74
+ point = usecomputer.mapPointFromCoordMap({ point: { x, y }, coordMap: parsed });
51
75
  }
52
76
  catch {
53
- // sharp not available — send PNG
54
- jpegBuffer = pngBuffer;
77
+ // If mapping fails, fall back to raw coords
55
78
  }
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
- }
67
- finally {
68
- try {
69
- fs.unlinkSync(tmpPath);
70
- }
71
- catch { }
72
79
  }
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 } };
80
+ await usecomputer.click({ point, button: "left", count: 1 });
81
+ return { success: true, data: { action: "click", x, y, mappedX: point.x, mappedY: point.y, coordMap: coordMap || "none" } };
80
82
  }
81
83
  async function doType(params) {
82
84
  const usecomputer = await getUC();
@@ -108,6 +110,22 @@ async function doScroll(params) {
108
110
  await usecomputer.scroll({ direction, amount, at: null });
109
111
  return { success: true, data: { action: "scroll", direction, amount } };
110
112
  }
113
+ async function doDrag(params) {
114
+ const usecomputer = await getUC();
115
+ const coordMap = params.coordMap ? String(params.coordMap) : lastCoordMap;
116
+ let from = { x: Number(params.fromX), y: Number(params.fromY) };
117
+ let to = { x: Number(params.toX), y: Number(params.toY) };
118
+ if (coordMap) {
119
+ try {
120
+ const parsed = usecomputer.parseCoordMapOrThrow(coordMap);
121
+ from = usecomputer.mapPointFromCoordMap({ point: from, coordMap: parsed });
122
+ to = usecomputer.mapPointFromCoordMap({ point: to, coordMap: parsed });
123
+ }
124
+ catch { }
125
+ }
126
+ await usecomputer.drag({ from, to, durationMs: null, button: null });
127
+ return { success: true, data: { action: "drag" } };
128
+ }
111
129
  async function listWindows() {
112
130
  const usecomputer = await getUC();
113
131
  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
@@ -118,8 +133,29 @@ async function interactiveSetup(existing) {
118
133
  rl.question(` ${prompt}`, (ans) => resolve(ans.trim() || def || ""));
119
134
  });
120
135
  console.log(" First-time setup:\n");
121
- const token = await ask("Gateway token", existing?.gatewayToken);
122
- const vm = await ask("VM address (IP or hostname)", existing?.vmAddress);
136
+ let token = await ask("Gateway token", existing?.gatewayToken);
137
+ let vm = await ask("VM address (IP or hostname)", existing?.vmAddress);
138
+ // Input validation: detect swapped fields
139
+ const isHexLong = (s) => /^[0-9a-f]{32,}$/i.test(s);
140
+ const isIPish = (s) => /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(s);
141
+ if (isHexLong(vm)) {
142
+ console.log(chalk.yellow("\n That VM address looks like a gateway token (long hex string)."));
143
+ console.log(chalk.yellow(" The VM address should be an IP like 104.237.145.128"));
144
+ const swap = await ask("Did you swap the fields? Enter your VM IP now");
145
+ if (swap) {
146
+ token = vm;
147
+ vm = swap;
148
+ }
149
+ }
150
+ else if (isIPish(token)) {
151
+ console.log(chalk.yellow("\n That gateway token looks like an IP address."));
152
+ console.log(chalk.yellow(" The gateway token should be a long hex string."));
153
+ const swap = await ask("Did you swap the fields? Enter your gateway token now");
154
+ if (swap) {
155
+ vm = token;
156
+ token = swap;
157
+ }
158
+ }
123
159
  const portStr = await ask("Port", String(existing?.port || 8765));
124
160
  rl.close();
125
161
  return {
@@ -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
@@ -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
- /delete/i, /remove/i, /destroy/i, /format/i, /erase/i,
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
- /sudo\s/i, /rm\s+-rf/i, /shutdown/i, /reboot/i,
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.2.0",
3
+ "version": "0.3.1",
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": {