@phnx-labs/agents-cli 1.22.103 → 1.22.105
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/CHANGELOG.md +39 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +2 -2
- package/dist/commands/computer.d.ts +100 -79
- package/dist/commands/computer.js +290 -830
- package/dist/commands/run-device-picker.d.ts +58 -0
- package/dist/commands/run-device-picker.js +222 -0
- package/dist/commands/setup-computer.js +20 -1
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/view.js +4 -6
- package/dist/lib/account-catalog.d.ts +22 -1
- package/dist/lib/account-catalog.js +72 -38
- package/dist/lib/accounting/usage.js +18 -14
- package/dist/lib/agent-spec/agents.d.ts +1 -0
- package/dist/lib/agent-spec/agents.js +1 -1
- package/dist/lib/browser/drivers/ssh.js +1 -1
- package/dist/lib/computer/context.d.ts +83 -0
- package/dist/lib/computer/context.js +91 -0
- package/dist/lib/computer/policy.d.ts +46 -0
- package/dist/lib/computer/policy.js +160 -0
- package/dist/lib/computer/record.d.ts +38 -0
- package/dist/lib/computer/record.js +86 -0
- package/dist/lib/computer/sessions-list.js +6 -6
- package/dist/lib/computer-client.d.ts +150 -0
- package/dist/lib/computer-client.js +222 -0
- package/dist/lib/exec.js +35 -1
- package/dist/lib/harness/adapters/claude.d.ts +37 -0
- package/dist/lib/harness/adapters/claude.js +69 -0
- package/dist/lib/helper-download.d.ts +1 -1
- package/dist/lib/helper-download.js +1 -1
- package/dist/lib/helper-versions.d.ts +9 -4
- package/dist/lib/helper-versions.js +8 -8
- package/dist/lib/installations/shims.js +8 -4
- package/dist/lib/menubar/download-menubar.d.ts +2 -1
- package/dist/lib/menubar/download-menubar.js +2 -1
- package/dist/lib/secrets-client.d.ts +3 -3
- package/dist/lib/secrets-client.js +15 -38
- package/dist/lib/session/db.js +1 -1
- package/dist/lib/sha256-asset.d.ts +2 -1
- package/dist/lib/sha256-asset.js +2 -1
- package/dist/lib/ssh-tunnel.d.ts +61 -0
- package/dist/lib/ssh-tunnel.js +105 -0
- package/dist/lib/summarizer/summarize.d.ts +2 -2
- package/dist/lib/summarizer/summarize.js +10 -3
- package/package.json +2 -3
- package/dist/commands/computer-actions.d.ts +0 -55
- package/dist/commands/computer-actions.js +0 -594
- package/dist/computer.d.ts +0 -2
- package/dist/computer.js +0 -7
- package/dist/lib/computer/actions.d.ts +0 -36
- package/dist/lib/computer/actions.js +0 -162
- package/dist/lib/computer/computer-rpc.d.ts +0 -39
- package/dist/lib/computer/computer-rpc.js +0 -447
- package/dist/lib/computer/des.d.ts +0 -1
- package/dist/lib/computer/des.js +0 -114
- package/dist/lib/computer/dispatch.d.ts +0 -10
- package/dist/lib/computer/dispatch.js +0 -133
- package/dist/lib/computer/download.d.ts +0 -54
- package/dist/lib/computer/download.js +0 -83
- package/dist/lib/computer/loop.d.ts +0 -62
- package/dist/lib/computer/loop.js +0 -98
- package/dist/lib/computer/model.d.ts +0 -44
- package/dist/lib/computer/model.js +0 -157
- package/dist/lib/computer/rfb-client.d.ts +0 -53
- package/dist/lib/computer/rfb-client.js +0 -562
- package/dist/lib/computer/ssh-tunnel.d.ts +0 -189
- package/dist/lib/computer/ssh-tunnel.js +0 -584
|
@@ -1,584 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared SSH port-forward tunnel + remote computer-helper provisioning.
|
|
3
|
-
*
|
|
4
|
-
* Two layers live here:
|
|
5
|
-
*
|
|
6
|
-
* 1. `startSSHTunnel` — the generic `ssh -L localPort:127.0.0.1:remotePort -N`
|
|
7
|
-
* spawn, extracted verbatim from the browser CDP driver so both the browser
|
|
8
|
-
* and `agents computer --device` reach a remote loopback service through one
|
|
9
|
-
* hardened tunnel. Behavior for the browser caller is unchanged (default,
|
|
10
|
-
* foreground, stderr-captured).
|
|
11
|
-
*
|
|
12
|
-
* 2. Remote computer-helper orchestration — resolve a registered device to an
|
|
13
|
-
* ssh target, push the cross-published Windows daemon exe, register it as a
|
|
14
|
-
* LOGON scheduled task (interactive session so real-desktop UIA/screenshot
|
|
15
|
-
* works and it survives the ssh disconnect), and open a tunnel the TS RPC
|
|
16
|
-
* client drives via TCP. Everything rides the existing `ssh-exec` /
|
|
17
|
-
* `devices/connect` primitives — no parallel SSH implementation.
|
|
18
|
-
*/
|
|
19
|
-
import { spawn } from 'child_process';
|
|
20
|
-
import * as net from 'net';
|
|
21
|
-
import * as fs from 'fs';
|
|
22
|
-
import * as path from 'path';
|
|
23
|
-
import { fileURLToPath } from 'url';
|
|
24
|
-
import { randomBytes } from 'crypto';
|
|
25
|
-
import { parseSha256Asset, sha256File } from '../sha256-asset.js';
|
|
26
|
-
import { Readable } from 'stream';
|
|
27
|
-
import { pipeline } from 'stream/promises';
|
|
28
|
-
import { sshExec, SSH_OPTS, assertValidSshTarget } from '../ssh-exec.js';
|
|
29
|
-
import { backgroundSpawnOptions } from '../platform/process.js';
|
|
30
|
-
import { encodePowerShell } from '../browser/drivers/ssh.js';
|
|
31
|
-
import { getDevice } from '../devices/registry.js';
|
|
32
|
-
import { deviceIdentityArgs, sshTargetFor } from '../devices/connect.js';
|
|
33
|
-
import { hostNameFor } from '../devices/ssh-config.js';
|
|
34
|
-
import { getCacheDir } from '../state.js';
|
|
35
|
-
import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
|
|
36
|
-
import { helperFloor, helperTag } from '../helper-versions.js';
|
|
37
|
-
/** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
|
|
38
|
-
*
|
|
39
|
-
* Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
|
|
40
|
-
* so the tunnel inherits the same options — crucially the keepalive, which lets
|
|
41
|
-
* a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
|
|
42
|
-
export function buildTunnelArgs(user, host, localPort, remotePort, extraSshArgs = []) {
|
|
43
|
-
return [
|
|
44
|
-
'-L',
|
|
45
|
-
`${localPort}:127.0.0.1:${remotePort}`,
|
|
46
|
-
`${user}@${host}`,
|
|
47
|
-
'-N',
|
|
48
|
-
...extraSshArgs,
|
|
49
|
-
...SSH_OPTS,
|
|
50
|
-
];
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
|
|
54
|
-
*
|
|
55
|
-
* Foreground (default): stderr is captured so a tunnel that dies inside 500ms
|
|
56
|
-
* rejects with the ssh error — the browser driver's original contract. Detached
|
|
57
|
-
* mode ignores stdio and `unref`s the child so the parent can exit while the
|
|
58
|
-
* tunnel lives; liveness is then confirmed by the caller probing the service.
|
|
59
|
-
*/
|
|
60
|
-
export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
|
|
61
|
-
return new Promise((resolve, reject) => {
|
|
62
|
-
// `user`/`host` can originate from a browser ssh:// profile or a device
|
|
63
|
-
// record. buildTunnelArgs places `${user}@${host}` before `-N`/SSH_OPTS, so
|
|
64
|
-
// a `-`-leading user would be parsed as an ssh option flag (option
|
|
65
|
-
// injection). Validate at the spawn sink so every caller is covered; reject
|
|
66
|
-
// (rather than throw synchronously) to keep the Promise contract.
|
|
67
|
-
try {
|
|
68
|
-
assertValidSshTarget(`${user}@${host}`);
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
reject(err);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const args = buildTunnelArgs(user, host, localPort, remotePort, opts.extraSshArgs);
|
|
75
|
-
const tunnel = spawn('ssh', args, {
|
|
76
|
-
stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
|
|
77
|
-
...(opts.detached ? backgroundSpawnOptions() : { detached: false, windowsHide: true }),
|
|
78
|
-
});
|
|
79
|
-
let stderr = '';
|
|
80
|
-
tunnel.stderr?.on('data', (data) => {
|
|
81
|
-
stderr += data.toString();
|
|
82
|
-
});
|
|
83
|
-
tunnel.on('error', (err) => {
|
|
84
|
-
reject(new Error(`SSH tunnel failed: ${err.message}`));
|
|
85
|
-
});
|
|
86
|
-
setTimeout(() => {
|
|
87
|
-
if (tunnel.killed) {
|
|
88
|
-
reject(new Error(`SSH tunnel died: ${stderr}`));
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
// Let the CLI exit without waiting on a persistent tunnel.
|
|
92
|
-
if (opts.detached)
|
|
93
|
-
tunnel.unref();
|
|
94
|
-
resolve(tunnel);
|
|
95
|
-
}
|
|
96
|
-
}, 500);
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
// 2. Remote computer-helper orchestration
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
/** Loopback TCP port the Windows daemon binds on the remote (Program.cs default). */
|
|
103
|
-
export const REMOTE_HELPER_PORT = 8765;
|
|
104
|
-
/** Task Scheduler task name for the daemon. Stable so setup/stop pair up. */
|
|
105
|
-
export const REMOTE_TASK_NAME = 'AgentsComputerHelper';
|
|
106
|
-
/** Basename of the cross-published exe under native/computer-win/dist. */
|
|
107
|
-
export const WIN_HELPER_EXE = 'computer-helper-win.exe';
|
|
108
|
-
/** Basename of the daemon's shared-secret token file under %LOCALAPPDATA%\agents. */
|
|
109
|
-
export const WIN_HELPER_TOKEN_FILE = 'helper-token';
|
|
110
|
-
/**
|
|
111
|
-
* Locate a locally built Windows daemon exe — a repo-checkout build output from
|
|
112
|
-
* `scripts/build-win.sh`, or one bundled next to the package. Local paths are
|
|
113
|
-
* only the first candidate; npm-installed CLIs have neither and fall through to
|
|
114
|
-
* the release-asset download (`ensureWinHelperExe`).
|
|
115
|
-
*/
|
|
116
|
-
export function resolveWinHelperExe() {
|
|
117
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
118
|
-
const candidates = [
|
|
119
|
-
// Running from the agents-cli checkout. cli/dist/lib/computer -> repo root (4 up) -> native/computer-win.
|
|
120
|
-
path.resolve(here, '..', '..', '..', '..', 'native', 'computer-win', 'dist', WIN_HELPER_EXE),
|
|
121
|
-
// Bundled with the npm package (dist/lib/computer -> dist/).
|
|
122
|
-
path.resolve(here, '..', '..', 'computer-helper-win', WIN_HELPER_EXE),
|
|
123
|
-
];
|
|
124
|
-
for (const c of candidates) {
|
|
125
|
-
if (fs.existsSync(c))
|
|
126
|
-
return c;
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
/** GitHub repo whose `computer-win/v<version>` releases carry the exe as an asset. */
|
|
131
|
-
export const WIN_HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
|
|
132
|
-
/** Cache dir for downloaded helper exes, one subdir per release tag. */
|
|
133
|
-
export function winHelperCacheDir(version) {
|
|
134
|
-
return path.join(getCacheDir(), 'computer', 'win-helper', `v${version}`);
|
|
135
|
-
}
|
|
136
|
-
/** Release-asset URLs for the exe + its checksum at one exact `computer-win/v<version>` tag. */
|
|
137
|
-
export function winHelperAssetUrls(version) {
|
|
138
|
-
// The helper's OWN tag, not the CLI's. This exe is not an .app bundle so it
|
|
139
|
-
// cannot share helper-download.ts's zip/codesign machinery, but it had the
|
|
140
|
-
// identical coupling: keyed to `v${cliVersion}`, every CLI release had to
|
|
141
|
-
// re-stage a ~165MB exe or the download 404'd, and a helper fix could not
|
|
142
|
-
// reach anyone without a CLI release. Same fix, same tag namespace.
|
|
143
|
-
const base = `https://github.com/${WIN_HELPER_RELEASE_REPO}/releases/download/${helperTag('computer-win', version)}`;
|
|
144
|
-
return { exe: `${base}/${WIN_HELPER_EXE}`, sha256: `${base}/${WIN_HELPER_EXE}.sha256` };
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Parse the published `.sha256` asset — `sha256sum` format (`<hex> <name>`) or
|
|
148
|
-
* a bare hex digest. Throws on anything that does not lead with 64 hex chars.
|
|
149
|
-
*/
|
|
150
|
-
/**
|
|
151
|
-
* Download the exe release asset for this HELPER version, verify its sha256
|
|
152
|
-
* against the published `.sha256` asset, and cache it under the agents cache
|
|
153
|
-
* dir. Only the exact `computer-win/v<version>` tag is consulted — a missing
|
|
154
|
-
* asset is a hard error naming that tag, never a silent fallback to another
|
|
155
|
-
* release. The version is the HELPER's own, not the CLI's.
|
|
156
|
-
*/
|
|
157
|
-
export async function downloadWinHelperExe(version) {
|
|
158
|
-
const cached = path.join(winHelperCacheDir(version), WIN_HELPER_EXE);
|
|
159
|
-
if (fs.existsSync(cached))
|
|
160
|
-
return cached;
|
|
161
|
-
// The SAME tag the URL is built from. This said `v${version}` — the CLI's tag
|
|
162
|
-
// shape — while winHelperAssetUrls uses helperTag('computer-win', ...), so a
|
|
163
|
-
// missing asset sent you looking for a tag that does not exist. The mac path
|
|
164
|
-
// had this fixed already; the Windows path was left behind by the tag split.
|
|
165
|
-
const tag = helperTag('computer-win', version);
|
|
166
|
-
const { exe: exeUrl, sha256: shaUrl } = winHelperAssetUrls(version);
|
|
167
|
-
const missing = (status, url) => new Error(`no ${WIN_HELPER_EXE} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
|
|
168
|
-
`The Windows helper ships as a GitHub release asset on its own helper tag; ` +
|
|
169
|
-
`from a repo checkout you can build it locally instead: bash scripts/build-win.sh`);
|
|
170
|
-
// Checksum first: it is tiny and 404s fast when the tag has no assets.
|
|
171
|
-
const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
|
|
172
|
-
if (!shaRes.ok)
|
|
173
|
-
throw missing(shaRes.status, shaUrl);
|
|
174
|
-
const expected = parseSha256Asset(await shaRes.text());
|
|
175
|
-
console.error(`Downloading ${WIN_HELPER_EXE} ${tag} from GitHub releases (~160 MB)...`);
|
|
176
|
-
const exeRes = await fetch(exeUrl, { signal: AbortSignal.timeout(15 * 60_000) });
|
|
177
|
-
if (!exeRes.ok || !exeRes.body)
|
|
178
|
-
throw missing(exeRes.status, exeUrl);
|
|
179
|
-
fs.mkdirSync(path.dirname(cached), { recursive: true });
|
|
180
|
-
// Stream to a partial file and rename only after the checksum passes, so an
|
|
181
|
-
// interrupted download can never be picked up as a valid cache hit.
|
|
182
|
-
const partial = `${cached}.download`;
|
|
183
|
-
try {
|
|
184
|
-
await pipeline(Readable.fromWeb(exeRes.body), fs.createWriteStream(partial));
|
|
185
|
-
const actual = await sha256File(partial);
|
|
186
|
-
if (actual !== expected) {
|
|
187
|
-
throw new Error(`sha256 mismatch for ${exeUrl}: expected ${expected}, got ${actual}`);
|
|
188
|
-
}
|
|
189
|
-
fs.renameSync(partial, cached);
|
|
190
|
-
}
|
|
191
|
-
finally {
|
|
192
|
-
fs.rmSync(partial, { force: true });
|
|
193
|
-
}
|
|
194
|
-
return cached;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Resolve the helper exe for `setup --device`: local build outputs first (repo
|
|
198
|
-
* checkout / bundled), then the checksum-verified release-asset download for
|
|
199
|
-
* the running CLI version. Throws with the tag it checked when neither exists.
|
|
200
|
-
*/
|
|
201
|
-
export async function ensureWinHelperExe(version = helperFloor('computer-win')) {
|
|
202
|
-
const local = resolveWinHelperExe();
|
|
203
|
-
if (local)
|
|
204
|
-
return local;
|
|
205
|
-
return downloadWinHelperExe(version);
|
|
206
|
-
}
|
|
207
|
-
function remoteStateDir() {
|
|
208
|
-
return path.join(getCacheDir(), 'computer', 'remote');
|
|
209
|
-
}
|
|
210
|
-
/** State file path for a device. Device names are ssh-alias safe (validated). */
|
|
211
|
-
export function remoteStatePath(device) {
|
|
212
|
-
return path.join(remoteStateDir(), `${device}.json`);
|
|
213
|
-
}
|
|
214
|
-
export function readRemoteState(device) {
|
|
215
|
-
try {
|
|
216
|
-
return JSON.parse(fs.readFileSync(remoteStatePath(device), 'utf-8'));
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
export function writeRemoteState(state) {
|
|
223
|
-
const dir = remoteStateDir();
|
|
224
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
225
|
-
fs.writeFileSync(remoteStatePath(state.device), JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
226
|
-
}
|
|
227
|
-
export function clearRemoteState(device) {
|
|
228
|
-
try {
|
|
229
|
-
fs.unlinkSync(remoteStatePath(device));
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
/* already gone */
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
/**
|
|
236
|
-
* Local file holding the shared-secret token for a device's helper daemon.
|
|
237
|
-
* Written at `setup --device` (0600), read at `start --device` so the RPC client
|
|
238
|
-
* authenticates. The remote daemon reads the same value from its own
|
|
239
|
-
* `--token-file`; the CLI is the source of truth and never round-trips it back.
|
|
240
|
-
*/
|
|
241
|
-
export function helperTokenPath(device) {
|
|
242
|
-
return path.join(remoteStateDir(), `${device}.token`);
|
|
243
|
-
}
|
|
244
|
-
export function readHelperToken(device) {
|
|
245
|
-
try {
|
|
246
|
-
const t = fs.readFileSync(helperTokenPath(device), 'utf-8').trim();
|
|
247
|
-
return t.length > 0 ? t : null;
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
export function writeHelperToken(device, token) {
|
|
254
|
-
const dir = remoteStateDir();
|
|
255
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
256
|
-
fs.writeFileSync(helperTokenPath(device), token, { mode: 0o600 });
|
|
257
|
-
}
|
|
258
|
-
export function clearHelperToken(device) {
|
|
259
|
-
try {
|
|
260
|
-
fs.unlinkSync(helperTokenPath(device));
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
/* already gone */
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
/** Resolve a registered device to its ssh pieces, or throw a clear error. */
|
|
267
|
-
export async function resolveRemoteDevice(name) {
|
|
268
|
-
const device = await getDevice(name);
|
|
269
|
-
if (!device) {
|
|
270
|
-
throw new Error(`Unknown device '${name}'. Register it with \`agents devices add\` / \`agents devices sync\`, then retry.`);
|
|
271
|
-
}
|
|
272
|
-
if (device.platform !== 'windows') {
|
|
273
|
-
throw new Error(`Device '${name}' is ${device.platform}, not windows. \`agents computer --device\` drives the Windows computer-helper daemon.`);
|
|
274
|
-
}
|
|
275
|
-
const target = sshTargetFor(device); // validates address + injection guard
|
|
276
|
-
const host = hostNameFor(device); // sshTargetFor already threw if absent
|
|
277
|
-
const user = device.user || process.env.USER || 'Administrator';
|
|
278
|
-
return { device, target, user, host };
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Single-quote a string for embedding inside a PowerShell literal.
|
|
282
|
-
*/
|
|
283
|
-
function psSingleQuote(s) {
|
|
284
|
-
return "'" + s.replace(/'/g, "''") + "'";
|
|
285
|
-
}
|
|
286
|
-
/**
|
|
287
|
-
* PowerShell that resolves the destination under %LOCALAPPDATA%\agents and
|
|
288
|
-
* stops any running instance first so the file is not locked. The caller copies
|
|
289
|
-
* the exe with scp and then verifies the byte count separately.
|
|
290
|
-
*/
|
|
291
|
-
export function buildPushScript() {
|
|
292
|
-
return [
|
|
293
|
-
`$dir = Join-Path $env:LOCALAPPDATA 'agents'`,
|
|
294
|
-
`New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
|
|
295
|
-
`$dst = Join-Path $dir '${WIN_HELPER_EXE}'`,
|
|
296
|
-
`Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
|
|
297
|
-
`Write-Output $dst`,
|
|
298
|
-
].join('; ');
|
|
299
|
-
}
|
|
300
|
-
/** PowerShell that verifies scp wrote the expected number of bytes. */
|
|
301
|
-
export function buildVerifyPushScript(remotePath, expectedBytes) {
|
|
302
|
-
return [
|
|
303
|
-
`$dst = ${psSingleQuote(remotePath)}`,
|
|
304
|
-
`$item = Get-Item -LiteralPath $dst -ErrorAction Stop`,
|
|
305
|
-
`if ($item.Length -ne ${expectedBytes}) { throw "helper copy length mismatch: expected ${expectedBytes}, got $($item.Length)" }`,
|
|
306
|
-
`Write-Output "$dst $($item.Length)"`,
|
|
307
|
-
].join('; ');
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* PowerShell that registers the daemon as a LOGON scheduled task. Interactive
|
|
311
|
-
* logon type + Highest run level so the daemon runs in the real desktop session
|
|
312
|
-
* (UIAutomation and ScreenCapture need a live session, not Session 0) and
|
|
313
|
-
* survives ssh disconnect — the same rationale as the browser WMI launch. The
|
|
314
|
-
* task is started immediately so the caller need not log out/in.
|
|
315
|
-
*/
|
|
316
|
-
/**
|
|
317
|
-
* PowerShell that writes the shared-secret token under %LOCALAPPDATA%\agents
|
|
318
|
-
* with an owner-only ACL (inheritance removed, read granted only to the current
|
|
319
|
-
* user) and echoes the resolved path so the caller can pass it to `--token-file`.
|
|
320
|
-
*/
|
|
321
|
-
export function buildWriteTokenScript(token) {
|
|
322
|
-
return [
|
|
323
|
-
`$dir = Join-Path $env:LOCALAPPDATA 'agents'`,
|
|
324
|
-
`New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
|
|
325
|
-
`$tok = Join-Path $dir '${WIN_HELPER_TOKEN_FILE}'`,
|
|
326
|
-
`Set-Content -LiteralPath $tok -Value ${psSingleQuote(token)} -NoNewline -Encoding ascii`,
|
|
327
|
-
`icacls $tok /inheritance:r /grant:r ("$($env:USERNAME):(R)") | Out-Null`,
|
|
328
|
-
`Write-Output $tok`,
|
|
329
|
-
].join('; ');
|
|
330
|
-
}
|
|
331
|
-
export function buildRegisterTaskScript(port, taskName, tokenPath) {
|
|
332
|
-
const argument = `--port ${port} --token-file "${tokenPath}"`;
|
|
333
|
-
return [
|
|
334
|
-
`$exe = Join-Path (Join-Path $env:LOCALAPPDATA 'agents') '${WIN_HELPER_EXE}'`,
|
|
335
|
-
`$action = New-ScheduledTaskAction -Execute $exe -Argument ${psSingleQuote(argument)}`,
|
|
336
|
-
`$trigger = New-ScheduledTaskTrigger -AtLogOn`,
|
|
337
|
-
`$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest`,
|
|
338
|
-
`$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)`,
|
|
339
|
-
`Register-ScheduledTask -TaskName '${taskName}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`,
|
|
340
|
-
`Start-ScheduledTask -TaskName '${taskName}'`,
|
|
341
|
-
].join('; ');
|
|
342
|
-
}
|
|
343
|
-
/** PowerShell that unregisters the task and stops any running daemon process. */
|
|
344
|
-
export function buildUnregisterTaskScript(taskName) {
|
|
345
|
-
return [
|
|
346
|
-
`Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction SilentlyContinue`,
|
|
347
|
-
`Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
|
|
348
|
-
].join('; ');
|
|
349
|
-
}
|
|
350
|
-
/** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */
|
|
351
|
-
export function scpRemotePath(remotePath) {
|
|
352
|
-
return remotePath.replace(/\\/g, '/');
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Build the scp argv used for the helper exe transfer. Exported so tests can
|
|
356
|
-
* assert the real binary copy path keeps BatchMode and does not route bytes
|
|
357
|
-
* through a PowerShell decoder.
|
|
358
|
-
*/
|
|
359
|
-
export function buildScpArgs(target, remotePath, filePath, extraSshArgs = []) {
|
|
360
|
-
return [...extraSshArgs, ...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Copy a local file to the remote destination with scp. This is a binary
|
|
364
|
-
* transfer; no base64 transform runs on either side.
|
|
365
|
-
*/
|
|
366
|
-
function copyFileOverScp(target, remotePath, filePath, extraSshArgs = [], timeoutMs = 600_000) {
|
|
367
|
-
return new Promise((resolve, reject) => {
|
|
368
|
-
const child = spawn('scp', buildScpArgs(target, remotePath, filePath, extraSshArgs), {
|
|
369
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
370
|
-
});
|
|
371
|
-
let stderr = '';
|
|
372
|
-
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
373
|
-
const timer = setTimeout(() => {
|
|
374
|
-
child.kill('SIGKILL');
|
|
375
|
-
reject(new Error(`scp push to ${target} timed out after ${timeoutMs}ms`));
|
|
376
|
-
}, timeoutMs);
|
|
377
|
-
const fail = (e) => {
|
|
378
|
-
clearTimeout(timer);
|
|
379
|
-
child.kill('SIGKILL');
|
|
380
|
-
reject(e);
|
|
381
|
-
};
|
|
382
|
-
child.on('error', fail);
|
|
383
|
-
child.on('close', (code) => {
|
|
384
|
-
clearTimeout(timer);
|
|
385
|
-
resolve({ code, stderr });
|
|
386
|
-
});
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
/**
|
|
390
|
-
* `setup --device`: push the exe, then register + start the LOGON task. Remote
|
|
391
|
-
* PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
|
|
392
|
-
* the browser driver and `agents ssh` use), and the large exe rides a binary
|
|
393
|
-
* scp transfer. Throws with the remote stderr on any failure.
|
|
394
|
-
*/
|
|
395
|
-
export async function setupRemoteHelper(name) {
|
|
396
|
-
const { device, target } = await resolveRemoteDevice(name);
|
|
397
|
-
const identityArgs = deviceIdentityArgs(device);
|
|
398
|
-
// Local build output, else the checksum-verified GitHub release asset for
|
|
399
|
-
// this CLI version. Throws naming the tag it checked when neither exists.
|
|
400
|
-
const exe = await ensureWinHelperExe();
|
|
401
|
-
const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000, extraSshArgs: identityArgs });
|
|
402
|
-
if (prep.code !== 0) {
|
|
403
|
-
throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
|
|
404
|
-
}
|
|
405
|
-
const remotePath = prep.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
406
|
-
if (!remotePath) {
|
|
407
|
-
throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
|
|
408
|
-
}
|
|
409
|
-
const push = await copyFileOverScp(target, remotePath, exe, identityArgs);
|
|
410
|
-
if (push.code !== 0) {
|
|
411
|
-
throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
|
|
412
|
-
}
|
|
413
|
-
const expectedBytes = fs.statSync(exe).size;
|
|
414
|
-
const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
|
|
415
|
-
if (verify.code !== 0) {
|
|
416
|
-
throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
|
|
417
|
-
}
|
|
418
|
-
// Provision the auth token: generate it locally, write it on the remote with
|
|
419
|
-
// an owner-only ACL, and register the task with --token-file. The daemon now
|
|
420
|
-
// refuses to start without a token, so a token-less (open-to-any-local-process)
|
|
421
|
-
// daemon can no longer be stood up through the CLI.
|
|
422
|
-
const token = generateToken();
|
|
423
|
-
const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000, extraSshArgs: identityArgs });
|
|
424
|
-
if (tokWrite.code !== 0) {
|
|
425
|
-
throw new Error(`writing helper token on '${name}' failed (exit ${tokWrite.code ?? 'null'}): ${tokWrite.stderr.trim() || tokWrite.stdout.trim()}`);
|
|
426
|
-
}
|
|
427
|
-
const remoteTokenPath = tokWrite.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
428
|
-
if (!remoteTokenPath) {
|
|
429
|
-
throw new Error(`writing helper token on '${name}' did not return a destination path`);
|
|
430
|
-
}
|
|
431
|
-
// Register + start the LOGON task, pointing it at the token file.
|
|
432
|
-
const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME, remoteTokenPath)), {
|
|
433
|
-
timeoutMs: 60_000,
|
|
434
|
-
extraSshArgs: identityArgs,
|
|
435
|
-
});
|
|
436
|
-
if (reg.code !== 0) {
|
|
437
|
-
throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
|
|
438
|
-
}
|
|
439
|
-
// Persist the token locally so `start --device` authenticates to the daemon.
|
|
440
|
-
writeHelperToken(name, token);
|
|
441
|
-
return { target, taskName: REMOTE_TASK_NAME };
|
|
442
|
-
}
|
|
443
|
-
/** Reserve a free local TCP port by binding :0 and reading the assigned port. */
|
|
444
|
-
export function pickFreePort() {
|
|
445
|
-
return new Promise((resolve, reject) => {
|
|
446
|
-
const srv = net.createServer();
|
|
447
|
-
srv.once('error', reject);
|
|
448
|
-
srv.listen(0, '127.0.0.1', () => {
|
|
449
|
-
const addr = srv.address();
|
|
450
|
-
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
451
|
-
srv.close(() => (port ? resolve(port) : reject(new Error('could not reserve a local port'))));
|
|
452
|
-
});
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
/**
|
|
456
|
-
* `start --device`: open a detached ssh -L tunnel to the remote daemon, verify it
|
|
457
|
-
* answers over TCP, and persist the tunnel state so verbs can reconnect. Returns
|
|
458
|
-
* the state (and leaves the tunnel running in the background).
|
|
459
|
-
*/
|
|
460
|
-
export async function startRemoteTunnel(name) {
|
|
461
|
-
const { device, target, user, host } = await resolveRemoteDevice(name);
|
|
462
|
-
const remotePort = REMOTE_HELPER_PORT;
|
|
463
|
-
const localPort = await pickFreePort();
|
|
464
|
-
const tunnel = await startSSHTunnel(user, host, localPort, remotePort, {
|
|
465
|
-
detached: true,
|
|
466
|
-
extraSshArgs: deviceIdentityArgs(device),
|
|
467
|
-
});
|
|
468
|
-
const tunnelPid = tunnel.pid ?? 0;
|
|
469
|
-
// Verify the daemon answers through the tunnel before we record it. This is
|
|
470
|
-
// the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
|
|
471
|
-
// The daemon now requires a token, so the probe must authenticate with the one
|
|
472
|
-
// provisioned at `setup`. A missing token means setup predates auth — guide
|
|
473
|
-
// the user to re-run it rather than silently failing the RPC.
|
|
474
|
-
const token = readHelperToken(name);
|
|
475
|
-
const prevTcp = process.env.COMPUTER_HELPER_TCP;
|
|
476
|
-
const prevTok = process.env.COMPUTER_HELPER_TOKEN;
|
|
477
|
-
process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${localPort}`;
|
|
478
|
-
if (token)
|
|
479
|
-
process.env.COMPUTER_HELPER_TOKEN = token;
|
|
480
|
-
else
|
|
481
|
-
delete process.env.COMPUTER_HELPER_TOKEN;
|
|
482
|
-
const client = openComputerClient();
|
|
483
|
-
let ok = false;
|
|
484
|
-
let probeErr = '';
|
|
485
|
-
try {
|
|
486
|
-
const r = await client.call('list_apps');
|
|
487
|
-
ok = !r.error;
|
|
488
|
-
if (r.error)
|
|
489
|
-
probeErr = `${r.error.code}: ${r.error.message}`;
|
|
490
|
-
}
|
|
491
|
-
catch (e) {
|
|
492
|
-
probeErr = e.message;
|
|
493
|
-
}
|
|
494
|
-
finally {
|
|
495
|
-
await client.close();
|
|
496
|
-
if (prevTcp === undefined)
|
|
497
|
-
delete process.env.COMPUTER_HELPER_TCP;
|
|
498
|
-
else
|
|
499
|
-
process.env.COMPUTER_HELPER_TCP = prevTcp;
|
|
500
|
-
if (prevTok === undefined)
|
|
501
|
-
delete process.env.COMPUTER_HELPER_TOKEN;
|
|
502
|
-
else
|
|
503
|
-
process.env.COMPUTER_HELPER_TOKEN = prevTok;
|
|
504
|
-
}
|
|
505
|
-
if (!ok) {
|
|
506
|
-
try {
|
|
507
|
-
if (tunnelPid)
|
|
508
|
-
process.kill(tunnelPid);
|
|
509
|
-
}
|
|
510
|
-
catch { /* gone */ }
|
|
511
|
-
const hint = token
|
|
512
|
-
? `Is it installed? Run: agents computer setup --device ${name}`
|
|
513
|
-
: `No auth token on record for '${name}' — re-run: agents computer setup --device ${name}`;
|
|
514
|
-
throw new Error(`tunnel to '${name}' opened but the daemon did not answer (${probeErr}). ${hint}`);
|
|
515
|
-
}
|
|
516
|
-
const state = {
|
|
517
|
-
device: name,
|
|
518
|
-
target,
|
|
519
|
-
localPort,
|
|
520
|
-
remotePort,
|
|
521
|
-
tunnelPid,
|
|
522
|
-
token,
|
|
523
|
-
taskName: REMOTE_TASK_NAME,
|
|
524
|
-
startedAt: Date.now(),
|
|
525
|
-
};
|
|
526
|
-
writeRemoteState(state);
|
|
527
|
-
return state;
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* `stop --device`: kill the local tunnel, unregister the remote task (best-effort
|
|
531
|
-
* — the box may be offline), and clear the persisted state.
|
|
532
|
-
*/
|
|
533
|
-
export async function stopRemoteHelper(name) {
|
|
534
|
-
const state = readRemoteState(name);
|
|
535
|
-
let tunnelKilled = false;
|
|
536
|
-
if (state?.tunnelPid) {
|
|
537
|
-
try {
|
|
538
|
-
process.kill(state.tunnelPid);
|
|
539
|
-
tunnelKilled = true;
|
|
540
|
-
}
|
|
541
|
-
catch {
|
|
542
|
-
/* already gone */
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
let taskRemoved = false;
|
|
546
|
-
try {
|
|
547
|
-
const { device, target } = await resolveRemoteDevice(name);
|
|
548
|
-
const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), {
|
|
549
|
-
timeoutMs: 60_000,
|
|
550
|
-
extraSshArgs: deviceIdentityArgs(device),
|
|
551
|
-
});
|
|
552
|
-
taskRemoved = res.code === 0;
|
|
553
|
-
}
|
|
554
|
-
catch {
|
|
555
|
-
/* device gone / offline — local teardown still succeeds */
|
|
556
|
-
}
|
|
557
|
-
clearRemoteState(name);
|
|
558
|
-
clearHelperToken(name);
|
|
559
|
-
return { tunnelKilled, taskRemoved };
|
|
560
|
-
}
|
|
561
|
-
/**
|
|
562
|
-
* Point this process's RPC client at a device's live tunnel by setting
|
|
563
|
-
* COMPUTER_HELPER_TCP / COMPUTER_HELPER_TOKEN from persisted state. Called for
|
|
564
|
-
* remote verbs (`apps --device`, `click --device`, …) so the shared
|
|
565
|
-
* openComputerClient() transparently selects the TcpClient transport — no
|
|
566
|
-
* per-verb wiring. Exits with guidance when there is no active tunnel.
|
|
567
|
-
*/
|
|
568
|
-
export function hydrateRemoteEnvFromState(name) {
|
|
569
|
-
const state = readRemoteState(name);
|
|
570
|
-
if (!state) {
|
|
571
|
-
console.error(`No active remote tunnel for '${name}'.`);
|
|
572
|
-
console.error(`Run: agents computer start --device ${name}`);
|
|
573
|
-
process.exit(1);
|
|
574
|
-
}
|
|
575
|
-
process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${state.localPort}`;
|
|
576
|
-
if (state.token)
|
|
577
|
-
process.env.COMPUTER_HELPER_TOKEN = state.token;
|
|
578
|
-
// Touch resolveTcpEndpoint so a later platform-gate check sees the endpoint.
|
|
579
|
-
void resolveTcpEndpoint();
|
|
580
|
-
}
|
|
581
|
-
/** Generate a shared-secret token (reserved for token-file provisioning). */
|
|
582
|
-
export function generateToken() {
|
|
583
|
-
return randomBytes(24).toString('hex');
|
|
584
|
-
}
|