harnery 0.46.1 → 0.47.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/README.md +24 -0
- package/bin/README.md +10 -0
- package/bin/agent-coord.mjs +3 -0
- package/bin/agent-hook.mjs +3 -0
- package/bin/harn.mjs +3 -0
- package/bin/run.mjs +43 -0
- package/dist/commander.d.ts +18 -6
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +74 -2
- package/dist/commands/agents.d.ts +25 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -27
- package/dist/commands/autoupdate.d.ts +12 -0
- package/dist/commands/autoupdate.d.ts.map +1 -0
- package/dist/commands/autoupdate.js +190 -0
- package/dist/commands/computer.d.ts +45 -0
- package/dist/commands/computer.d.ts.map +1 -0
- package/dist/commands/computer.js +509 -0
- package/dist/commands/docs.js +3 -1
- package/dist/commands/fetch.js +11 -1
- package/dist/commands/grep.d.ts +72 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +200 -47
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/rm.js +2 -2
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +2 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -3
- package/dist/core/agents/coord-bin.d.ts +3 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -1
- package/dist/core/agents/coord-bin.js +6 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +6 -2
- package/dist/core/agents/render/prompt-context.d.ts +55 -7
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +122 -48
- package/dist/core/agents/state/proc-start.d.ts +3 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +26 -4
- package/dist/core/autoupdate/engine.d.ts +97 -0
- package/dist/core/autoupdate/engine.d.ts.map +1 -0
- package/dist/core/autoupdate/engine.js +548 -0
- package/dist/core/autoupdate/fingerprint.d.ts +16 -0
- package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
- package/dist/core/autoupdate/fingerprint.js +73 -0
- package/dist/core/autoupdate/git.d.ts +30 -0
- package/dist/core/autoupdate/git.d.ts.map +1 -0
- package/dist/core/autoupdate/git.js +82 -0
- package/dist/core/autoupdate/index.d.ts +6 -0
- package/dist/core/autoupdate/index.d.ts.map +1 -0
- package/dist/core/autoupdate/index.js +4 -0
- package/dist/core/autoupdate/state.d.ts +59 -0
- package/dist/core/autoupdate/state.d.ts.map +1 -0
- package/dist/core/autoupdate/state.js +214 -0
- package/dist/core/autoupdate/types.d.ts +113 -0
- package/dist/core/autoupdate/types.d.ts.map +1 -0
- package/dist/core/autoupdate/types.js +35 -0
- package/dist/core/config.d.ts +53 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +114 -1
- package/dist/core/events/v3/bootstrap.js +1 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +1 -0
- package/dist/core/hooks/adapter/wiring.d.ts +12 -2
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +38 -8
- package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
- package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
- package/dist/core/hooks/autoupdate-cue.js +53 -0
- package/dist/core/hooks/cli.js +28 -3
- package/dist/core/hooks/effects/index.js +4 -4
- package/dist/core/hooks/resolve/coord-root.js +1 -0
- package/dist/core/semantic/contract.d.ts +3 -3
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +4 -2
- package/dist/core/storage/modes.d.ts +1 -1
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +5 -1
- package/dist/core/workflow/durable-record.d.ts +2 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -1
- package/dist/core/workflow/durable-record.js +13 -1
- package/dist/core/workflow/engine.js +4 -0
- package/dist/core/workflow/spawn-claude.d.ts +7 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +16 -4
- package/dist/core/workflow/specialists.d.ts.map +1 -1
- package/dist/core/workflow/specialists.js +1 -0
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -9
- package/dist/lib/computer/cua.d.ts +50 -0
- package/dist/lib/computer/cua.d.ts.map +1 -0
- package/dist/lib/computer/cua.js +474 -0
- package/dist/lib/computer/host.d.ts +19 -0
- package/dist/lib/computer/host.d.ts.map +1 -0
- package/dist/lib/computer/host.js +72 -0
- package/dist/lib/computer/lease.d.ts +13 -0
- package/dist/lib/computer/lease.d.ts.map +1 -0
- package/dist/lib/computer/lease.js +269 -0
- package/dist/lib/computer/types.d.ts +163 -0
- package/dist/lib/computer/types.d.ts.map +1 -0
- package/dist/lib/computer/types.js +10 -0
- package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
- package/dist/lib/docs-metadata-v2.js +6 -5
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +1 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +5 -4
- package/dist/lib/headless/index.d.ts +33 -0
- package/dist/lib/headless/index.d.ts.map +1 -1
- package/dist/lib/headless/index.js +38 -2
- package/dist/lib/http/client.d.ts +2 -9
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +30 -9
- package/dist/lib/instructions/apply.d.ts +9 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +92 -14
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
- package/dist/lib/instructions/git-hooks.js +8 -4
- package/dist/lib/instructions/templates.d.ts +11 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +81 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +89 -1
- package/src/commander.ts +98 -8
- package/src/commands/agents.ts +100 -32
- package/src/commands/autoupdate.ts +250 -0
- package/src/commands/computer.ts +682 -0
- package/src/commands/docs.ts +3 -1
- package/src/commands/fetch.ts +11 -1
- package/src/commands/grep.ts +274 -59
- package/src/commands/init.ts +17 -7
- package/src/commands/rm.ts +2 -2
- package/src/commands/web.ts +2 -0
- package/src/core/agents/canonical-emit.ts +2 -3
- package/src/core/agents/coord-bin.ts +30 -0
- package/src/core/agents/coord-client.ts +6 -2
- package/src/core/agents/render/prompt-context.ts +152 -53
- package/src/core/agents/state/proc-start.ts +29 -5
- package/src/core/autoupdate/engine.ts +691 -0
- package/src/core/autoupdate/fingerprint.ts +89 -0
- package/src/core/autoupdate/git.ts +120 -0
- package/src/core/autoupdate/index.ts +29 -0
- package/src/core/autoupdate/state.ts +269 -0
- package/src/core/autoupdate/types.ts +135 -0
- package/src/core/config.ts +142 -1
- package/src/core/events/v3/bootstrap.ts +1 -0
- package/src/core/events/v3/runtime-build.ts +1 -0
- package/src/core/hooks/adapter/wiring.ts +51 -6
- package/src/core/hooks/autoupdate-cue.ts +72 -0
- package/src/core/hooks/cli.ts +27 -1
- package/src/core/hooks/effects/index.ts +4 -4
- package/src/core/hooks/resolve/coord-root.ts +1 -0
- package/src/core/semantic/service.ts +6 -2
- package/src/core/storage/modes.ts +7 -1
- package/src/core/workflow/durable-record.ts +15 -1
- package/src/core/workflow/engine.ts +3 -0
- package/src/core/workflow/spawn-claude.ts +16 -4
- package/src/core/workflow/specialists.ts +1 -0
- package/src/core/workflow/types.ts +10 -0
- package/src/core/workflow/workspaces/leases.ts +1 -9
- package/src/lib/computer/cua.ts +572 -0
- package/src/lib/computer/host.ts +87 -0
- package/src/lib/computer/lease.ts +299 -0
- package/src/lib/computer/types.ts +177 -0
- package/src/lib/docs-metadata-v2.ts +24 -5
- package/src/lib/exec.ts +1 -0
- package/src/lib/guarded-remove.ts +5 -3
- package/src/lib/headless/index.ts +66 -2
- package/src/lib/http/client.ts +33 -19
- package/src/lib/instructions/apply.ts +100 -12
- package/src/lib/instructions/git-hooks.ts +8 -4
- package/src/lib/instructions/templates.ts +95 -1
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Design record: decision 0015 (portable coordination-aware workflows).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { ClaudeMcpMode } from "../../lib/headless/index.ts";
|
|
10
11
|
import type { DiagnosticAdvice } from "../diagnostics/contract.ts";
|
|
11
12
|
import type {
|
|
12
13
|
DispatchCostEstimator,
|
|
@@ -342,6 +343,11 @@ export interface AgentOpts {
|
|
|
342
343
|
* Mixed-adapter workflows are legal: triage on one CLI, deep work on
|
|
343
344
|
* another. */
|
|
344
345
|
adapter?: AdapterName;
|
|
346
|
+
/** MCP servers a Claude Code child may load (default `"none"`: no MCP
|
|
347
|
+
* servers, no claude.ai connectors). Set `"inherit"` for a stage that really
|
|
348
|
+
* calls a connector, or `{ mcpServers: {...} }` for exactly those servers.
|
|
349
|
+
* Other adapters ignore it. */
|
|
350
|
+
mcp?: ClaudeMcpMode;
|
|
345
351
|
}
|
|
346
352
|
|
|
347
353
|
/** Open registry key. The built-in catalog currently contains Claude Code,
|
|
@@ -436,6 +442,10 @@ export interface SpawnRequest {
|
|
|
436
442
|
* spawner refuses it when its adapter declares no full-access rendering, and
|
|
437
443
|
* refuses it alongside a filesystem policy, which it would contradict. */
|
|
438
444
|
access?: WorkerAccessMode;
|
|
445
|
+
/** MCP servers a Claude Code child may load. Absent means `"none"`: no MCP
|
|
446
|
+
* servers and no claude.ai connectors. `"inherit"` or an explicit server map
|
|
447
|
+
* is the per-spawn opt-in (see `ClaudeMcpMode`). Other adapters ignore it. */
|
|
448
|
+
mcp?: ClaudeMcpMode;
|
|
439
449
|
}
|
|
440
450
|
|
|
441
451
|
/**
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { hostname } from "node:os";
|
|
18
18
|
import { basename, dirname, join, resolve } from "node:path";
|
|
19
19
|
import { stateDirMode, stateFileMode } from "../../storage/modes.ts";
|
|
20
|
+
import { fsyncDirectory } from "../durable-record.ts";
|
|
20
21
|
|
|
21
22
|
const MAX_LEASE_BYTES = 8 * 1024;
|
|
22
23
|
const MAX_CONTENTION_OBSERVATION_ATTEMPTS = 8;
|
|
@@ -568,15 +569,6 @@ function digestBytes(bytes: string): string {
|
|
|
568
569
|
return createHash("sha256").update(bytes).digest("hex");
|
|
569
570
|
}
|
|
570
571
|
|
|
571
|
-
function fsyncDirectory(path: string): void {
|
|
572
|
-
const fd = openSync(path, "r");
|
|
573
|
-
try {
|
|
574
|
-
fsyncSync(fd);
|
|
575
|
-
} finally {
|
|
576
|
-
closeSync(fd);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
572
|
function safeUnlink(path: string): void {
|
|
581
573
|
try {
|
|
582
574
|
unlinkSync(path);
|
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cua Driver adapter for `harn computer` (ADR 0228).
|
|
3
|
+
*
|
|
4
|
+
* Everything specific to this driver lives here: the pinned release and its
|
|
5
|
+
* archive hashes, the archive layout, the driver's preference file, and the
|
|
6
|
+
* `call` / `list-tools` / `describe` / `status` / `serve` / `stop` arguments.
|
|
7
|
+
* The command layer and the desktop lease see only `DesktopDriver`.
|
|
8
|
+
*
|
|
9
|
+
* Under WSL the Windows build runs through interop: the `/mnt/<drive>/...`
|
|
10
|
+
* executable is spawned directly, environment variables cross through
|
|
11
|
+
* `WSLENV`, and file paths in tool arguments and results are translated with
|
|
12
|
+
* `wslpath`.
|
|
13
|
+
*
|
|
14
|
+
* Internal module: not exported from package.json.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
17
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
renameSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
statSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { posix, win32 } from "node:path";
|
|
28
|
+
import { windowsPathToWsl, wslPathToWindows } from "../browser/windows-chrome.ts";
|
|
29
|
+
import { extractZip, listZip, type ZipLimits } from "../zip/index.ts";
|
|
30
|
+
import type {
|
|
31
|
+
DaemonStatus,
|
|
32
|
+
DesktopDriver,
|
|
33
|
+
DriverCallResult,
|
|
34
|
+
HostInfo,
|
|
35
|
+
InstallPlan,
|
|
36
|
+
InstallResult,
|
|
37
|
+
PinnedArtifact,
|
|
38
|
+
} from "./types.ts";
|
|
39
|
+
|
|
40
|
+
export const CUA_DRIVER_ID = "cua";
|
|
41
|
+
export const CUA_DRIVER_VERSION = "0.38.0";
|
|
42
|
+
|
|
43
|
+
const RELEASE_BASE = `https://github.com/trycua/cua/releases/download/cua-driver-rs-v${CUA_DRIVER_VERSION}`;
|
|
44
|
+
|
|
45
|
+
/** Windows-side architectures with a pinned build. */
|
|
46
|
+
export type CuaArch = "x86_64" | "arm64";
|
|
47
|
+
|
|
48
|
+
function pin(arch: CuaArch, sha256: string): PinnedArtifact {
|
|
49
|
+
const stem = `cua-driver-rs-${CUA_DRIVER_VERSION}-windows-${arch}`;
|
|
50
|
+
return {
|
|
51
|
+
version: CUA_DRIVER_VERSION,
|
|
52
|
+
fileName: `${stem}.zip`,
|
|
53
|
+
url: `${RELEASE_BASE}/${stem}.zip`,
|
|
54
|
+
sha256,
|
|
55
|
+
executableEntry: `${stem}/cua-driver.exe`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pinned archives keyed by `windows-<arch>`. Upgrading is a change here. */
|
|
60
|
+
export const CUA_PINS: Readonly<Record<`windows-${CuaArch}`, PinnedArtifact>> = Object.freeze({
|
|
61
|
+
"windows-x86_64": pin(
|
|
62
|
+
"x86_64",
|
|
63
|
+
"997f20114ad3db227a854e1e5a886234f76405b155f4af70605d4b7ca4a436a1",
|
|
64
|
+
),
|
|
65
|
+
"windows-arm64": pin("arm64", "c442872360cbb819e288b9f30daa7e3d2693d5945495d1400bcd90af5bd16402"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Preferences merged into the driver's config before its first run. */
|
|
69
|
+
export const CUA_PREFERENCES: Readonly<Record<string, unknown>> = Object.freeze({
|
|
70
|
+
telemetry_enabled: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const CUA_TELEMETRY_ENV = "CUA_DRIVER_RS_TELEMETRY_ENABLED";
|
|
74
|
+
export const INSTALL_MARKER = ".harnery-install.json";
|
|
75
|
+
export const CALL_TIMEOUT_MS = 120_000;
|
|
76
|
+
export const STATUS_TIMEOUT_MS = 15_000;
|
|
77
|
+
export const START_DEADLINE_MS = 10_000;
|
|
78
|
+
const START_POLL_MS = 250;
|
|
79
|
+
|
|
80
|
+
/** The release's executables are tens of MB each; allow headroom over the defaults. */
|
|
81
|
+
const ARCHIVE_LIMITS: Partial<ZipLimits> = {
|
|
82
|
+
archiveBytes: 256 * 1024 * 1024,
|
|
83
|
+
entryBytes: 128 * 1024 * 1024,
|
|
84
|
+
totalBytes: 512 * 1024 * 1024,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Arg keys whose string values are file paths in the caller's syntax. */
|
|
88
|
+
const PATH_ARG_KEYS = new Set([
|
|
89
|
+
"screenshot_out_file",
|
|
90
|
+
"output_dir",
|
|
91
|
+
"path",
|
|
92
|
+
"paths",
|
|
93
|
+
"image_path",
|
|
94
|
+
"file_path",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
export interface SpawnResult {
|
|
98
|
+
status: number | null;
|
|
99
|
+
stdout: string;
|
|
100
|
+
stderr: string;
|
|
101
|
+
error?: Error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type SpawnSyncFn = (
|
|
105
|
+
command: string,
|
|
106
|
+
args: string[],
|
|
107
|
+
options: { timeout: number; env: NodeJS.ProcessEnv },
|
|
108
|
+
) => SpawnResult;
|
|
109
|
+
|
|
110
|
+
export interface CuaDeps {
|
|
111
|
+
/** HTTP fetch for the archive download. */
|
|
112
|
+
fetch?: (url: string, init?: RequestInit) => Promise<Response>;
|
|
113
|
+
/** Synchronous process runner for the driver, cmd.exe and powershell.exe. */
|
|
114
|
+
spawnSync?: SpawnSyncFn;
|
|
115
|
+
/** Detached background start (native Windows `serve`). */
|
|
116
|
+
spawnDetached?: (command: string, args: string[], env: NodeJS.ProcessEnv) => void;
|
|
117
|
+
/** Pin table override (tests). Defaults to `CUA_PINS`. */
|
|
118
|
+
pins?: Readonly<Record<`windows-${CuaArch}`, PinnedArtifact>>;
|
|
119
|
+
/** Process architecture used to pick the pin. Defaults to `process.arch`. */
|
|
120
|
+
arch?: string;
|
|
121
|
+
/** Base environment for driver runs. Defaults to `process.env`. */
|
|
122
|
+
env?: NodeJS.ProcessEnv;
|
|
123
|
+
/** `%USERPROFILE%` as a Windows path (WSL reads it through cmd.exe). */
|
|
124
|
+
windowsUserProfile?: (host: HostInfo) => string | undefined;
|
|
125
|
+
/** `wslpath <flag> <path>`; returns undefined when the conversion fails. */
|
|
126
|
+
wslpath?: (flag: "-w" | "-u", path: string) => string | undefined;
|
|
127
|
+
sleep?: (ms: number) => Promise<void>;
|
|
128
|
+
now?: () => number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface InstallMarker {
|
|
132
|
+
version: string;
|
|
133
|
+
sha256: string;
|
|
134
|
+
installed_at: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pathApi(host: HostInfo): typeof posix {
|
|
138
|
+
return host.kind === "windows" ? win32 : posix;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function requireSupported(host: HostInfo): string {
|
|
142
|
+
if (host.kind === "unsupported" || !host.desktopStateDir)
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Computer use is not supported on this host: ${host.reason ?? "no shared desktop state directory"}`,
|
|
145
|
+
);
|
|
146
|
+
return host.desktopStateDir;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Map a process architecture onto a pinned Windows build. */
|
|
150
|
+
export function cuaArch(arch: string): CuaArch | undefined {
|
|
151
|
+
if (arch === "x64" || arch === "x86_64" || arch === "amd64") return "x86_64";
|
|
152
|
+
if (arch === "arm64" || arch === "aarch64") return "arm64";
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalize(text: string | undefined): string {
|
|
157
|
+
return (text ?? "").replace(/\r\n?/g, "\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseJson(stdout: string): unknown {
|
|
161
|
+
const trimmed = stdout.trim();
|
|
162
|
+
if (!trimmed) return undefined;
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(trimmed);
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const defaultSpawnSync: SpawnSyncFn = (command, args, options) => {
|
|
171
|
+
const result = spawnSync(command, args, {
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
timeout: options.timeout,
|
|
174
|
+
env: options.env,
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
status: result.status,
|
|
180
|
+
stdout: result.stdout ?? "",
|
|
181
|
+
stderr: result.stderr ?? "",
|
|
182
|
+
error: result.error,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const defaultSpawnDetached = (command: string, args: string[], env: NodeJS.ProcessEnv): void => {
|
|
187
|
+
spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true, env }).unref();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Create a Cua Driver adapter. Every side effect is injectable for tests. */
|
|
191
|
+
export function createCuaDriver(deps: CuaDeps = {}): DesktopDriver {
|
|
192
|
+
const run = deps.spawnSync ?? defaultSpawnSync;
|
|
193
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
194
|
+
const now = deps.now ?? Date.now;
|
|
195
|
+
const baseEnv = (): NodeJS.ProcessEnv => deps.env ?? process.env;
|
|
196
|
+
let userProfileCache: string | undefined;
|
|
197
|
+
|
|
198
|
+
const windowsUserProfile =
|
|
199
|
+
deps.windowsUserProfile ??
|
|
200
|
+
((host: HostInfo): string | undefined => {
|
|
201
|
+
if (host.kind === "windows") return baseEnv().USERPROFILE?.trim() || undefined;
|
|
202
|
+
const result = run("cmd.exe", ["/c", "echo %USERPROFILE%"], {
|
|
203
|
+
timeout: STATUS_TIMEOUT_MS,
|
|
204
|
+
env: baseEnv(),
|
|
205
|
+
});
|
|
206
|
+
const value = normalize(result.stdout).trim();
|
|
207
|
+
return result.status === 0 && /^[A-Za-z]:\\/.test(value) ? value : undefined;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const wslpath =
|
|
211
|
+
deps.wslpath ??
|
|
212
|
+
((flag: "-w" | "-u", path: string): string | undefined => {
|
|
213
|
+
const result = run("wslpath", [flag, path], { timeout: STATUS_TIMEOUT_MS, env: baseEnv() });
|
|
214
|
+
const value = normalize(result.stdout).trim();
|
|
215
|
+
return result.status === 0 && value ? value : undefined;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
function artifactFor(): PinnedArtifact {
|
|
219
|
+
const arch = deps.arch ?? process.arch;
|
|
220
|
+
const mapped = cuaArch(arch);
|
|
221
|
+
if (!mapped)
|
|
222
|
+
throw new Error(
|
|
223
|
+
`No pinned Cua Driver build for architecture ${arch}; supported: x64 (x86_64), arm64.`,
|
|
224
|
+
);
|
|
225
|
+
return (deps.pins ?? CUA_PINS)[`windows-${mapped}`];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function paths(host: HostInfo): { installDir: string; executable: string } {
|
|
229
|
+
const stateDir = requireSupported(host);
|
|
230
|
+
const p = pathApi(host);
|
|
231
|
+
const installDir = p.join(stateDir, "tools", "cua-driver", CUA_DRIVER_VERSION);
|
|
232
|
+
return { installDir, executable: p.join(installDir, "cua-driver.exe") };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function preferenceFile(host: HostInfo): string {
|
|
236
|
+
requireSupported(host);
|
|
237
|
+
if (!userProfileCache) {
|
|
238
|
+
const profile = windowsUserProfile(host);
|
|
239
|
+
if (!profile)
|
|
240
|
+
throw new Error(
|
|
241
|
+
"Could not resolve the Windows user profile directory (%USERPROFILE%) for the driver's preference file.",
|
|
242
|
+
);
|
|
243
|
+
userProfileCache = profile;
|
|
244
|
+
}
|
|
245
|
+
if (host.kind === "windows") return win32.join(userProfileCache, ".cua-driver", "config.json");
|
|
246
|
+
return posix.join(windowsPathToWsl(userProfileCache), ".cua-driver", "config.json");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readMarker(installDir: string, p: typeof posix): InstallMarker | undefined {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(readFileSync(p.join(installDir, INSTALL_MARKER), "utf8")) as InstallMarker;
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function installedExecutable(host: HostInfo): string | undefined {
|
|
258
|
+
if (host.kind === "unsupported" || !host.desktopStateDir) return undefined;
|
|
259
|
+
let artifact: PinnedArtifact;
|
|
260
|
+
try {
|
|
261
|
+
artifact = artifactFor();
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const { installDir, executable } = paths(host);
|
|
266
|
+
const marker = readMarker(installDir, pathApi(host));
|
|
267
|
+
if (!marker || marker.version !== artifact.version || marker.sha256 !== artifact.sha256)
|
|
268
|
+
return undefined;
|
|
269
|
+
return existsSync(executable) ? executable : undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function driverEnv(host: HostInfo): NodeJS.ProcessEnv {
|
|
273
|
+
const env: NodeJS.ProcessEnv = { ...baseEnv(), [CUA_TELEMETRY_ENV]: "0" };
|
|
274
|
+
if (host.kind === "wsl") {
|
|
275
|
+
const entries = (env.WSLENV ?? "").split(":").filter(Boolean);
|
|
276
|
+
if (!entries.some((entry) => entry.split("/")[0] === CUA_TELEMETRY_ENV))
|
|
277
|
+
entries.push(CUA_TELEMETRY_ENV);
|
|
278
|
+
env.WSLENV = entries.join(":");
|
|
279
|
+
}
|
|
280
|
+
return env;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function requireExecutable(host: HostInfo): string {
|
|
284
|
+
requireSupported(host);
|
|
285
|
+
const exe = installedExecutable(host);
|
|
286
|
+
if (!exe)
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Cua Driver ${CUA_DRIVER_VERSION} is not installed for this desktop; install it with the computer install command first.`,
|
|
289
|
+
);
|
|
290
|
+
return exe;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function exec(host: HostInfo, args: string[], timeout: number): DriverCallResult {
|
|
294
|
+
const exe = requireExecutable(host);
|
|
295
|
+
const result = run(exe, args, { timeout, env: driverEnv(host) });
|
|
296
|
+
const stdout = normalize(result.stdout);
|
|
297
|
+
let stderr = normalize(result.stderr);
|
|
298
|
+
if (result.error) {
|
|
299
|
+
const code = (result.error as NodeJS.ErrnoException).code;
|
|
300
|
+
const note =
|
|
301
|
+
code === "ETIMEDOUT"
|
|
302
|
+
? `cua-driver ${args[0]} timed out after ${Math.round(timeout / 1000)}s`
|
|
303
|
+
: `cua-driver ${args[0]} failed to run: ${result.error.message}`;
|
|
304
|
+
stderr = stderr ? `${stderr.replace(/\n$/, "")}\n${note}\n` : `${note}\n`;
|
|
305
|
+
}
|
|
306
|
+
const out: DriverCallResult = {
|
|
307
|
+
exitCode: result.status ?? (result.error ? -1 : 1),
|
|
308
|
+
stdout,
|
|
309
|
+
stderr,
|
|
310
|
+
};
|
|
311
|
+
const json = parseJson(stdout);
|
|
312
|
+
if (json !== undefined) out.json = json;
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function daemonStatus(host: HostInfo): DaemonStatus {
|
|
317
|
+
const result = exec(host, ["status"], STATUS_TIMEOUT_MS);
|
|
318
|
+
const text = result.stdout.trim() || result.stderr.trim();
|
|
319
|
+
const detail = text.split("\n").slice(0, 3).join("\n");
|
|
320
|
+
const running = result.exitCode === 0 && !/not running/i.test(result.stdout);
|
|
321
|
+
const status: DaemonStatus = { running, detail };
|
|
322
|
+
const pid = /pid:\s*(\d+)/i.exec(result.stdout);
|
|
323
|
+
if (running && pid) status.pid = Number(pid[1]);
|
|
324
|
+
return status;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function planInstall(host: HostInfo): InstallPlan {
|
|
328
|
+
requireSupported(host);
|
|
329
|
+
const artifact = artifactFor();
|
|
330
|
+
const { installDir, executable } = paths(host);
|
|
331
|
+
return {
|
|
332
|
+
driver: CUA_DRIVER_ID,
|
|
333
|
+
version: CUA_DRIVER_VERSION,
|
|
334
|
+
host: host.kind,
|
|
335
|
+
artifact,
|
|
336
|
+
installDir,
|
|
337
|
+
executable,
|
|
338
|
+
alreadyInstalled: installedExecutable(host) === executable,
|
|
339
|
+
preferenceFile: preferenceFile(host),
|
|
340
|
+
preferences: { ...CUA_PREFERENCES },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function mergePreferences(file: string, preferences: Record<string, unknown>, p: typeof posix) {
|
|
345
|
+
let existing: Record<string, unknown> = {};
|
|
346
|
+
if (existsSync(file)) {
|
|
347
|
+
const raw = readFileSync(file, "utf8").replace(/^/, "");
|
|
348
|
+
if (raw.trim()) {
|
|
349
|
+
let parsed: unknown;
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(raw);
|
|
352
|
+
} catch {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Driver preference file ${file} is not valid JSON; fix or remove it, then retry.`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
358
|
+
throw new Error(`Driver preference file ${file} is not a JSON object.`);
|
|
359
|
+
existing = parsed as Record<string, unknown>;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const merged = { ...existing, ...preferences };
|
|
363
|
+
mkdirSync(p.dirname(file), { recursive: true });
|
|
364
|
+
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
365
|
+
writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`);
|
|
366
|
+
renameSync(temp, file);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function download(url: string): Promise<Uint8Array> {
|
|
370
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
371
|
+
let response: Response;
|
|
372
|
+
try {
|
|
373
|
+
response = await fetchFn(url, { redirect: "follow" });
|
|
374
|
+
} catch (error) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Downloading ${url} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (!response.ok) throw new Error(`Downloading ${url} failed: HTTP ${response.status}`);
|
|
380
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function install(plan: InstallPlan): Promise<InstallResult> {
|
|
384
|
+
if (plan.host === "unsupported")
|
|
385
|
+
throw new Error("Computer use is not supported on this host; nothing to install.");
|
|
386
|
+
const p = plan.host === "windows" ? win32 : posix;
|
|
387
|
+
const artifact = plan.artifact;
|
|
388
|
+
const markerOk = (): boolean => {
|
|
389
|
+
const marker = readMarker(plan.installDir, p);
|
|
390
|
+
return (
|
|
391
|
+
!!marker &&
|
|
392
|
+
marker.version === artifact.version &&
|
|
393
|
+
marker.sha256 === artifact.sha256 &&
|
|
394
|
+
existsSync(plan.executable)
|
|
395
|
+
);
|
|
396
|
+
};
|
|
397
|
+
// Preferences go down before the binary can ever run, installed or not.
|
|
398
|
+
mergePreferences(plan.preferenceFile, plan.preferences, p);
|
|
399
|
+
if (markerOk()) return { ...plan, alreadyInstalled: true, installed: false };
|
|
400
|
+
|
|
401
|
+
const bytes = await download(artifact.url);
|
|
402
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
403
|
+
if (actual !== artifact.sha256.toLowerCase())
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Refusing ${artifact.fileName}: SHA-256 ${actual} does not match the pinned ${artifact.sha256}. Nothing was installed.`,
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const parent = p.dirname(plan.installDir);
|
|
409
|
+
mkdirSync(parent, { recursive: true });
|
|
410
|
+
const tag = `${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
411
|
+
const archive = p.join(parent, `.download-${tag}.zip`);
|
|
412
|
+
const stage = p.join(parent, `.stage-${tag}`);
|
|
413
|
+
try {
|
|
414
|
+
writeFileSync(archive, bytes);
|
|
415
|
+
const topDir = artifact.executableEntry.split("/")[0] as string;
|
|
416
|
+
const entries = listZip(archive, { limits: ARCHIVE_LIMITS });
|
|
417
|
+
const stray = entries.find((entry) => !entry.name.startsWith(`${topDir}/`));
|
|
418
|
+
if (stray) throw new Error(`Unexpected entry outside ${topDir}/ in archive: ${stray.name}`);
|
|
419
|
+
if (!entries.some((entry) => entry.name === artifact.executableEntry))
|
|
420
|
+
throw new Error(`Archive lacks ${artifact.executableEntry}.`);
|
|
421
|
+
extractZip(archive, stage, { limits: ARCHIVE_LIMITS });
|
|
422
|
+
const extracted = p.join(stage, topDir);
|
|
423
|
+
if (!statSync(extracted).isDirectory()) throw new Error(`Archive lacks ${topDir}/.`);
|
|
424
|
+
const marker: InstallMarker = {
|
|
425
|
+
version: artifact.version,
|
|
426
|
+
sha256: artifact.sha256,
|
|
427
|
+
installed_at: new Date(now()).toISOString(),
|
|
428
|
+
};
|
|
429
|
+
writeFileSync(p.join(extracted, INSTALL_MARKER), `${JSON.stringify(marker, null, 2)}\n`);
|
|
430
|
+
// A versioned dir without a matching marker is a broken earlier attempt.
|
|
431
|
+
if (existsSync(plan.installDir)) rmSync(plan.installDir, { recursive: true, force: true });
|
|
432
|
+
renameSync(extracted, plan.installDir);
|
|
433
|
+
} finally {
|
|
434
|
+
rmSync(archive, { force: true });
|
|
435
|
+
rmSync(stage, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
return { ...plan, alreadyInstalled: false, installed: true };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function startDaemon(host: HostInfo): Promise<DaemonStatus> {
|
|
441
|
+
const exe = requireExecutable(host);
|
|
442
|
+
const current = daemonStatus(host);
|
|
443
|
+
if (current.running) return current;
|
|
444
|
+
const env = driverEnv(host);
|
|
445
|
+
if (host.kind === "wsl") {
|
|
446
|
+
const windowsExe = toWindows(exe).replace(/'/g, "''");
|
|
447
|
+
const command = `Start-Process -FilePath '${windowsExe}' -ArgumentList 'serve' -WindowStyle Hidden`;
|
|
448
|
+
const result = run("powershell.exe", ["-NoProfile", "-Command", command], {
|
|
449
|
+
timeout: 30_000,
|
|
450
|
+
env,
|
|
451
|
+
});
|
|
452
|
+
if (result.status !== 0)
|
|
453
|
+
throw new Error(
|
|
454
|
+
`Starting the driver daemon failed: ${normalize(
|
|
455
|
+
result.stderr || result.stdout || result.error?.message || "no output",
|
|
456
|
+
)
|
|
457
|
+
.trim()
|
|
458
|
+
.slice(0, 400)}`,
|
|
459
|
+
);
|
|
460
|
+
} else {
|
|
461
|
+
(deps.spawnDetached ?? defaultSpawnDetached)(exe, ["serve"], env);
|
|
462
|
+
}
|
|
463
|
+
const deadline = now() + START_DEADLINE_MS;
|
|
464
|
+
let status = current;
|
|
465
|
+
while (now() < deadline) {
|
|
466
|
+
await sleep(START_POLL_MS);
|
|
467
|
+
status = daemonStatus(host);
|
|
468
|
+
if (status.running) return status;
|
|
469
|
+
}
|
|
470
|
+
return status;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function stopDaemon(host: HostInfo): DaemonStatus {
|
|
474
|
+
exec(host, ["stop"], STATUS_TIMEOUT_MS);
|
|
475
|
+
return daemonStatus(host);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function toWindows(path: string): string {
|
|
479
|
+
const converted = wslpath("-w", path);
|
|
480
|
+
if (converted) return converted;
|
|
481
|
+
if (/^\/mnt\/[a-z]\//.test(path)) return wslPathToWindows(path);
|
|
482
|
+
// wslpath can refuse a path that does not exist yet (an output file).
|
|
483
|
+
const dir = posix.dirname(path);
|
|
484
|
+
const parent = dir !== path ? wslpath("-w", dir) : undefined;
|
|
485
|
+
return parent ? `${parent.replace(/\\$/, "")}\\${posix.basename(path)}` : path;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function toCaller(path: string): string {
|
|
489
|
+
const converted = wslpath("-u", path);
|
|
490
|
+
if (converted) return converted;
|
|
491
|
+
try {
|
|
492
|
+
return windowsPathToWsl(path);
|
|
493
|
+
} catch {
|
|
494
|
+
return path;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function translateArgsPaths(
|
|
499
|
+
host: HostInfo,
|
|
500
|
+
_tool: string,
|
|
501
|
+
args: Record<string, unknown>,
|
|
502
|
+
): Record<string, unknown> {
|
|
503
|
+
if (host.kind !== "wsl") return args;
|
|
504
|
+
const convert = (value: unknown): unknown =>
|
|
505
|
+
typeof value === "string" && value.startsWith("/") ? toWindows(value) : value;
|
|
506
|
+
const out: Record<string, unknown> = { ...args };
|
|
507
|
+
for (const [key, value] of Object.entries(args)) {
|
|
508
|
+
if (!PATH_ARG_KEYS.has(key)) continue;
|
|
509
|
+
out[key] = Array.isArray(value) ? value.map(convert) : convert(value);
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function translateResultPaths(host: HostInfo, stdout: string): string {
|
|
515
|
+
if (host.kind !== "wsl") return stdout;
|
|
516
|
+
let parsed: unknown;
|
|
517
|
+
try {
|
|
518
|
+
parsed = JSON.parse(stdout);
|
|
519
|
+
} catch {
|
|
520
|
+
return stdout;
|
|
521
|
+
}
|
|
522
|
+
let changed = false;
|
|
523
|
+
// Strip the Win32 extended-length prefixes the driver may return:
|
|
524
|
+
// `\\?\UNC\host\share` -> `\\host\share`, `\\?\C:\x` -> `C:\x`.
|
|
525
|
+
const unprefix = (value: string): string =>
|
|
526
|
+
value.replace(/^\\\\\?\\UNC\\/i, "\\\\").replace(/^\\\\\?\\(?=[A-Za-z]:\\)/, "");
|
|
527
|
+
const isWindowsPath = (value: string): boolean =>
|
|
528
|
+
/^[A-Za-z]:\\/.test(unprefix(value)) || /^\\\\wsl/i.test(unprefix(value));
|
|
529
|
+
const isPathKey = (key: string): boolean =>
|
|
530
|
+
key === "path" || key === "paths" || key.endsWith("_path") || key.endsWith("_file");
|
|
531
|
+
const convert = (value: unknown): unknown => {
|
|
532
|
+
if (typeof value !== "string" || !isWindowsPath(value)) return value;
|
|
533
|
+
changed = true;
|
|
534
|
+
return toCaller(unprefix(value));
|
|
535
|
+
};
|
|
536
|
+
const walk = (node: unknown): unknown => {
|
|
537
|
+
if (Array.isArray(node)) return node.map(walk);
|
|
538
|
+
if (!node || typeof node !== "object") return node;
|
|
539
|
+
const out: Record<string, unknown> = {};
|
|
540
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
541
|
+
if (isPathKey(key) && typeof value === "string") out[key] = convert(value);
|
|
542
|
+
else if (isPathKey(key) && Array.isArray(value)) out[key] = value.map(convert);
|
|
543
|
+
else out[key] = walk(value);
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
};
|
|
547
|
+
const result = walk(parsed);
|
|
548
|
+
if (!changed) return stdout;
|
|
549
|
+
const pretty = stdout.trim().includes("\n");
|
|
550
|
+
const trailing = stdout.endsWith("\n") ? "\n" : "";
|
|
551
|
+
return `${pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)}${trailing}`;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
id: CUA_DRIVER_ID,
|
|
556
|
+
version: CUA_DRIVER_VERSION,
|
|
557
|
+
planInstall,
|
|
558
|
+
install,
|
|
559
|
+
installedExecutable,
|
|
560
|
+
daemonStatus,
|
|
561
|
+
startDaemon,
|
|
562
|
+
stopDaemon,
|
|
563
|
+
call: (host, tool, args) => exec(host, ["call", tool, JSON.stringify(args)], CALL_TIMEOUT_MS),
|
|
564
|
+
listTools: (host) => exec(host, ["list-tools"], CALL_TIMEOUT_MS),
|
|
565
|
+
describe: (host, tool) => exec(host, ["describe", tool], CALL_TIMEOUT_MS),
|
|
566
|
+
health: (host) => exec(host, ["call", "health_report", "{}"], CALL_TIMEOUT_MS),
|
|
567
|
+
translateArgsPaths,
|
|
568
|
+
translateResultPaths,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export const cuaDriver: DesktopDriver = createCuaDriver();
|