@instaclaw/dispatch 0.1.1 → 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 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,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 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
+ 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 sharp = (await import("sharp")).default;
50
- jpegBuffer = await sharp(pngBuffer).jpeg({ quality: 80 }).toBuffer();
70
+ const parsed = usecomputer.parseCoordMapOrThrow(coordMap);
71
+ point = usecomputer.mapPointFromCoordMap({ point: { x, y }, coordMap: parsed });
51
72
  }
52
73
  catch {
53
- // sharp not available — send PNG
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
- finally {
68
- try {
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
@@ -9,13 +9,14 @@
9
9
  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
- import { closeSupervisor } from "./supervisor.js";
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") {
@@ -55,7 +56,7 @@ async function main() {
55
56
  gatewayToken: args.token,
56
57
  vmAddress: args.vm,
57
58
  port: args.port || 8765,
58
- mode: "supervised",
59
+ mode: args.autonomous ? "autonomous" : "supervised",
59
60
  };
60
61
  saveConfig(config);
61
62
  }
@@ -65,6 +66,10 @@ async function main() {
65
66
  saveConfig(config);
66
67
  console.log(` Config saved to ${getConfigPath()}\n`);
67
68
  }
69
+ // Set supervisor mode
70
+ if (config.mode === "autonomous") {
71
+ setMode("autonomous");
72
+ }
68
73
  console.log(` VM: ${config.vmAddress}:${config.port}`);
69
74
  console.log(` Mode: ${config.mode}`);
70
75
  console.log(` Token: ${config.gatewayToken.substring(0, 8)}...\n`);
@@ -77,9 +82,29 @@ async function main() {
77
82
  rejectUnauthorized: false, // Accept self-signed (TOFU)
78
83
  certFingerprint: config.certFingerprint,
79
84
  onConnect: () => {
80
- console.log(`\n Dispatch mode: SUPERVISED`);
81
- console.log(` Screenshots auto-approved. Actions require [Enter] to confirm.`);
82
- console.log(` Press Ctrl+C to disconnect.\n`);
85
+ if (config.mode === "autonomous") {
86
+ console.log(`\n Dispatch mode: AUTONOMOUS`);
87
+ console.log(` All actions auto-approved (dangerous actions still require confirmation).`);
88
+ }
89
+ else {
90
+ console.log(`\n Dispatch mode: SUPERVISED`);
91
+ console.log(` Screenshots auto-approved. Actions require [Enter] to confirm.`);
92
+ }
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
+ }
83
108
  },
84
109
  onDisconnect: () => {
85
110
  // Will auto-reconnect
@@ -133,6 +158,9 @@ function parseArgs() {
133
158
  else if ((args[i] === "--port" || args[i] === "-p") && args[i + 1]) {
134
159
  result.port = parseInt(args[++i], 10);
135
160
  }
161
+ else if (args[i] === "--autonomous" || args[i] === "--auto") {
162
+ result.autonomous = true;
163
+ }
136
164
  }
137
165
  return result;
138
166
  }
@@ -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
@@ -1,3 +1,4 @@
1
1
  import type { DispatchCommand } from "./types.js";
2
+ export declare function setMode(m: "supervised" | "autonomous"): void;
2
3
  export declare function requestApproval(command: DispatchCommand): Promise<boolean>;
3
4
  export declare function closeSupervisor(): void;
@@ -1,9 +1,32 @@
1
1
  /**
2
- * Supervisor — prompts user for approval before executing commands.
3
- * Screenshots are auto-approved. All other actions require Enter to proceed.
2
+ * Supervisor — controls whether commands need user approval.
3
+ *
4
+ * Modes:
5
+ * supervised (default) — all non-passive commands require Enter to approve
6
+ * autonomous — auto-approves most commands, but ALWAYS prompts for dangerous ones
4
7
  */
5
8
  import readline from "readline";
6
9
  const AUTO_APPROVE_TYPES = new Set(["screenshot", "windows", "status"]);
10
+ // Patterns that indicate dangerous actions — always require confirmation even in autonomous mode
11
+ const DANGEROUS_TEXT_PATTERNS = [
12
+ // Authentication
13
+ /password/i, /passwd/i, /secret/i, /credential/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
18
+ /purchase/i, /submit.*order/i, /confirm.*payment/i, /buy\s/i,
19
+ /transfer.*fund/i, /send.*money/i, /withdraw/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,
23
+ ];
24
+ const DANGEROUS_KEY_COMBOS = new Set([
25
+ "cmd+delete", "cmd+shift+delete", "ctrl+delete",
26
+ "cmd+q", "alt+f4",
27
+ "cmd+shift+backspace", // Empty Trash on macOS
28
+ ]);
29
+ let mode = "supervised";
7
30
  const isTTY = process.stdin.isTTY ?? false;
8
31
  let rl = null;
9
32
  if (isTTY) {
@@ -12,18 +35,36 @@ if (isTTY) {
12
35
  output: process.stdout,
13
36
  });
14
37
  }
38
+ export function setMode(m) {
39
+ mode = m;
40
+ }
15
41
  export async function requestApproval(command) {
16
- // Auto-approve passive commands
42
+ // Always auto-approve passive commands
17
43
  if (AUTO_APPROVE_TYPES.has(command.type)) {
18
44
  return true;
19
45
  }
20
46
  const desc = formatCommand(command);
21
- console.log(`\n Agent wants to: ${desc}`);
22
- // Non-interactive mode (no TTY) — auto-approve with log
47
+ const dangerous = isDangerous(command);
48
+ // Non-interactive (no TTY) — auto-approve everything (can't prompt)
23
49
  if (!rl) {
50
+ if (dangerous) {
51
+ console.log(`\n BLOCKED (dangerous, no TTY): ${desc}`);
52
+ return false;
53
+ }
54
+ console.log(`\n Agent wants to: ${desc}`);
24
55
  console.log(" Auto-approved (non-interactive mode)");
25
56
  return true;
26
57
  }
58
+ // Autonomous mode — auto-approve non-dangerous actions
59
+ if (mode === "autonomous" && !dangerous) {
60
+ console.log(`\n Agent: ${desc}`);
61
+ return true;
62
+ }
63
+ // Supervised mode OR dangerous action in autonomous mode — prompt user
64
+ console.log(`\n Agent wants to: ${desc}`);
65
+ if (dangerous) {
66
+ console.log(" ⚠️ DANGEROUS ACTION — requires confirmation");
67
+ }
27
68
  return new Promise((resolve) => {
28
69
  rl.question(" [Enter] to approve, [n] to deny: ", (answer) => {
29
70
  const denied = answer.trim().toLowerCase() === "n";
@@ -34,6 +75,18 @@ export async function requestApproval(command) {
34
75
  });
35
76
  });
36
77
  }
78
+ function isDangerous(cmd) {
79
+ const p = cmd.params;
80
+ if (cmd.type === "type") {
81
+ const text = String(p.text || "");
82
+ return DANGEROUS_TEXT_PATTERNS.some((pat) => pat.test(text));
83
+ }
84
+ if (cmd.type === "press") {
85
+ const key = String(p.key || "").toLowerCase();
86
+ return DANGEROUS_KEY_COMBOS.has(key);
87
+ }
88
+ return false;
89
+ }
37
90
  function formatCommand(cmd) {
38
91
  const p = cmd.params;
39
92
  switch (cmd.type) {
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.1.1",
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": {