@phnx-labs/agents-cli 1.22.74 → 1.22.75
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 +35 -0
- package/README.md +1 -1
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +26 -7
- package/dist/lib/account-registry.js +27 -6
- package/dist/lib/computer/computer-rpc.d.ts +6 -1
- package/dist/lib/computer/computer-rpc.js +23 -3
- package/dist/lib/computer/des.d.ts +1 -0
- package/dist/lib/computer/des.js +114 -0
- package/dist/lib/computer/rfb-client.d.ts +53 -0
- package/dist/lib/computer/rfb-client.js +562 -0
- package/dist/lib/daemon/auth-sync-service.js +3 -0
- package/dist/lib/daemon/daemon.js +9 -10
- package/dist/lib/daemon/usage-sync-service.js +3 -0
- package/dist/lib/daemon-webhooks.js +15 -2
- package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
- package/dist/lib/fleet-shared-repo-sync.js +101 -4
- package/dist/lib/session/remote/remote-list.d.ts +2 -0
- package/dist/lib/session/remote/remote-list.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.75
|
|
4
|
+
|
|
5
|
+
- **Account labels are per-harness, and survive a daemon publish (PHNX-3887).** One
|
|
6
|
+
identity signed into several harnesses can now carry the SAME label on each —
|
|
7
|
+
`claude#icloud`, `codex#icloud`, and `grok#icloud` all resolve to that harness's own
|
|
8
|
+
login, instead of the first-labelled harness squatting the name and forcing prefixes
|
|
9
|
+
(`cxicloud`, `gkicloud`) on the rest. Uniqueness is scoped to `(agent, label)`;
|
|
10
|
+
provider accounts, selected by bare name via `--account`, stay globally unique, and a
|
|
11
|
+
duplicate label within one harness is still refused. Separately, `agents accounts
|
|
12
|
+
label` wrote version-scoped rows into the central `agents.yaml` as a plain file write
|
|
13
|
+
with no commit, so the daemon's `publish <device> daemon state` tick — which committed
|
|
14
|
+
only the per-device doc and then rebased `--autostash` over the dirty central file —
|
|
15
|
+
silently destroyed them: every box lost its account labels on its next publish. The
|
|
16
|
+
publish now commits `agents.yaml` alongside the device doc, so labels persist and
|
|
17
|
+
actually sync fleet-wide as `accounts label --help` already promised. Source:
|
|
18
|
+
`cli/src/lib/account-registry.ts`, `cli/src/lib/fleet-shared-repo-sync.ts`.
|
|
19
|
+
|
|
20
|
+
- **Keep one daemon alive across concurrent stop/start (PHNX-3941).** Prevent a replacement daemon from exiting while stop holds the lifecycle lock, so concurrent stop/start cannot leave zero instances, and extend real-boot supervisor-wiring coverage to all 20 services. Source: `cli/src/lib/daemon/daemon.ts`.
|
|
21
|
+
|
|
22
|
+
- **`agents computer` can now drive a Linux GUI desktop over VNC/RFB (PHNX-3948).**
|
|
23
|
+
A third computer backend joins the macOS (Accessibility) and Windows (UIA) native
|
|
24
|
+
helpers — this one speaks the RFB/VNC protocol directly to an x11vnc/Xvnc server, so
|
|
25
|
+
a headless Linux desktop (or an LXD container exposing x11vnc on the host's Tailscale
|
|
26
|
+
IP) becomes drivable with no per-box native helper install. Select it with
|
|
27
|
+
`agents computer --vnc <host:port>` (port defaults to 5901; password via
|
|
28
|
+
`--vnc-password` or `COMPUTER_HELPER_VNC_PASSWORD`), then use the same verbs as the
|
|
29
|
+
other backends: `screenshot`, `click`, `right-click`, `type`, `type-text`, `key`,
|
|
30
|
+
`scroll`, `wait`. It is coordinate-based — RFB carries a framebuffer and pointer/key
|
|
31
|
+
events, not an accessibility tree — so the AX-tree verbs (`describe`, `get-text`,
|
|
32
|
+
`ax-action`, element-id targeting) and `launch` fail loud with a clear message rather
|
|
33
|
+
than silently no-op'ing. VNC authentication uses a bundled pure-JS DES (Node 24's
|
|
34
|
+
OpenSSL 3 keeps DES in the off-by-default legacy provider). Source:
|
|
35
|
+
`cli/src/lib/computer/rfb-client.ts`, `cli/src/lib/computer/des.ts`,
|
|
36
|
+
`cli/src/lib/computer/computer-rpc.ts`, `cli/src/commands/computer.ts`.
|
|
37
|
+
|
|
3
38
|
## 1.22.74
|
|
4
39
|
|
|
5
40
|
- **Launching an agent no longer dirties your tracked `.gitignore` (PHNX-3718).**
|
package/README.md
CHANGED
|
@@ -1756,7 +1756,7 @@ Agents are defined in [src/lib/agents.ts](cli/src/lib/agents.ts) -- each is a co
|
|
|
1756
1756
|
|---|---|
|
|
1757
1757
|
| [`cli`](cli) | **The CLI** (this README) — version management, config sync, sessions, teams, cloud, browser, computer, secrets. |
|
|
1758
1758
|
| [phnx-labs/agi-ext](https://github.com/phnx-labs/agi-ext) | **AGI EXT** — the VS Code extension (agent terminals as tabs + the Fleet dashboard). Moved to its own repo (RUSH-3189); separate product, own publish identity. |
|
|
1759
|
-
| [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). |
|
|
1759
|
+
| [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). A third, install-free backend drives a **Linux GUI desktop over VNC/RFB**: `agents computer --vnc <host:port> screenshot` (coordinate-based — screenshot/click/type/key/scroll against any x11vnc/Xvnc server). |
|
|
1760
1760
|
| [`packages/session-tracker`](packages/session-tracker) | The `SessionStart` hook that writes live-session state **AGI EXT** reads back (agi-ext `src/core/liveSession.ts`) — not the CLI, which reads transcripts. |
|
|
1761
1761
|
|
|
1762
1762
|
## Contributing
|
|
@@ -10,6 +10,7 @@ import { resolveHelperExec, resolveSocketPath } from '../lib/computer/computer-r
|
|
|
10
10
|
export declare function shouldBlockOffPlatform(opts: {
|
|
11
11
|
platform: NodeJS.Platform;
|
|
12
12
|
tcpConfigured: boolean;
|
|
13
|
+
vncConfigured?: boolean;
|
|
13
14
|
device?: string;
|
|
14
15
|
}): boolean;
|
|
15
16
|
/**
|
|
@@ -3,7 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { registerCommandGroups } from '../lib/help.js';
|
|
6
|
-
import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
|
|
6
|
+
import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, resolveVncEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
|
|
7
7
|
import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/computer/ssh-tunnel.js';
|
|
8
8
|
import { sshExec } from '../lib/ssh-exec.js';
|
|
9
9
|
import { encodePowershell } from '../lib/hosts/remote-cmd.js';
|
|
@@ -41,6 +41,8 @@ export function shouldBlockOffPlatform(opts) {
|
|
|
41
41
|
return false;
|
|
42
42
|
if (opts.tcpConfigured)
|
|
43
43
|
return false; // remote (Windows) daemon over a tunnel
|
|
44
|
+
if (opts.vncConfigured)
|
|
45
|
+
return false; // RFB/VNC desktop (Linux GUI over the wire)
|
|
44
46
|
if (opts.device)
|
|
45
47
|
return false; // remote path resolves its own endpoint
|
|
46
48
|
return true;
|
|
@@ -84,20 +86,37 @@ export function reconcileScreenshotExt(outPath, buf) {
|
|
|
84
86
|
export function registerComputerCommand(program) {
|
|
85
87
|
const computer = program
|
|
86
88
|
.command('computer')
|
|
87
|
-
.description('Drive macOS apps via Accessibility, or a remote Windows device with --device —
|
|
89
|
+
.description('Drive macOS apps via Accessibility, a Linux GUI desktop with --vnc, or a remote Windows device with --device — screenshot, click, type')
|
|
90
|
+
// A VNC/RFB desktop is driven over the wire (--vnc host:port, e.g. an x11vnc
|
|
91
|
+
// server on a headless Linux box or an LXD container). Set it before the gate.
|
|
92
|
+
.option('--vnc <host:port>', 'Drive a GUI desktop over VNC/RFB (x11vnc/Xvnc; port defaults to 5901) instead of a native helper')
|
|
93
|
+
.option('--vnc-password <password>', 'VNC password for --vnc (or set COMPUTER_HELPER_VNC_PASSWORD)')
|
|
88
94
|
// The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
|
|
89
|
-
// macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set,
|
|
90
|
-
// a `--device <name>` invocation). Fail fast with a clear
|
|
91
|
-
//
|
|
95
|
+
// macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, a
|
|
96
|
+
// --vnc desktop, or a `--device <name>` invocation). Fail fast with a clear
|
|
97
|
+
// message only when no remote path is available, instead of a downstream error.
|
|
92
98
|
.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
93
|
-
const
|
|
99
|
+
const globals = actionCommand.optsWithGlobals();
|
|
100
|
+
// --vnc selects the RFB transport for every verb under this command.
|
|
101
|
+
if (globals.vnc) {
|
|
102
|
+
process.env.COMPUTER_HELPER_VNC = globals.vnc;
|
|
103
|
+
if (globals.vncPassword)
|
|
104
|
+
process.env.COMPUTER_HELPER_VNC_PASSWORD = globals.vncPassword;
|
|
105
|
+
}
|
|
106
|
+
const device = globals.device;
|
|
94
107
|
// Verbs with --device reconnect to the tunnel `start --device` recorded;
|
|
95
108
|
// this sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
|
|
96
109
|
if (device && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
|
|
97
110
|
hydrateRemoteEnvFromState(device);
|
|
98
111
|
}
|
|
99
|
-
if (shouldBlockOffPlatform({
|
|
112
|
+
if (shouldBlockOffPlatform({
|
|
113
|
+
platform: process.platform,
|
|
114
|
+
tcpConfigured: resolveTcpEndpoint() != null,
|
|
115
|
+
vncConfigured: resolveVncEndpoint() != null,
|
|
116
|
+
device,
|
|
117
|
+
})) {
|
|
100
118
|
console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
|
|
119
|
+
console.error('For a Linux GUI desktop over VNC: `agents computer --vnc <host:port> screenshot`.');
|
|
101
120
|
console.error('For a remote Windows device: register it with `agents devices`, then use --device (or set COMPUTER_HELPER_TCP).');
|
|
102
121
|
process.exit(1);
|
|
103
122
|
}
|
|
@@ -187,11 +187,32 @@ function nativeRowsForNameOrId(meta, name) {
|
|
|
187
187
|
return [];
|
|
188
188
|
return nativeIdentityRows(meta, found.agent, found.identityKey);
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Native label names are unique per HARNESS, not globally (PHNX-3887).
|
|
192
|
+
*
|
|
193
|
+
* One human identity is commonly signed into several harnesses —
|
|
194
|
+
* `muqsitnawaz@icloud.com` is a claude login AND a codex login AND a grok login.
|
|
195
|
+
* A global namespace let whichever harness was labelled first squat the good
|
|
196
|
+
* name, forcing prefixed junk (`cxicloud`, `gkicloud`) on the rest. Nothing is
|
|
197
|
+
* actually ambiguous at the point of use: the selector is `<harness>#<label>`,
|
|
198
|
+
* and `findUnifiedAccount` already disambiguates via `preferAgent`.
|
|
199
|
+
*
|
|
200
|
+
* Pass `agent` to scope the check to that harness. Omit it for management
|
|
201
|
+
* lookups with no harness in hand (rename/remove), which keep the old
|
|
202
|
+
* fleet-wide check so a rename cannot collide with an unrelated harness's row.
|
|
203
|
+
*
|
|
204
|
+
* Provider (non-native) accounts stay globally unique — they are selected by
|
|
205
|
+
* bare name via `--account`, with no harness to scope them by.
|
|
206
|
+
*/
|
|
207
|
+
function assertUniqueUnifiedName(name, meta, doc, exceptIds, agent) {
|
|
191
208
|
const needle = name.toLowerCase();
|
|
192
|
-
const nativeHits = listNativeAccounts(meta).filter(account =>
|
|
193
|
-
|
|
194
|
-
|
|
209
|
+
const nativeHits = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent)
|
|
210
|
+
&& (account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle));
|
|
211
|
+
if (nativeHits.some(account => !exceptIds?.has(account.id))) {
|
|
212
|
+
throw new Error(agent === undefined
|
|
213
|
+
? `Account '${name}' already exists.`
|
|
214
|
+
: `Account '${name}' already exists for the ${agent} harness.`);
|
|
215
|
+
}
|
|
195
216
|
// Same laziness as findUnifiedAccount: a native row that already owns this
|
|
196
217
|
// name (even one we are mutating) means we never open the provider store.
|
|
197
218
|
if (nativeHits.length > 0)
|
|
@@ -203,7 +224,7 @@ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
|
|
|
203
224
|
export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
|
|
204
225
|
assertNativeLabel(name);
|
|
205
226
|
const meta = readMeta();
|
|
206
|
-
assertUniqueUnifiedName(name, meta);
|
|
227
|
+
assertUniqueUnifiedName(name, meta, undefined, undefined, agent);
|
|
207
228
|
const duplicate = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
|
|
208
229
|
if (duplicate)
|
|
209
230
|
throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
|
|
@@ -240,7 +261,7 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
|
|
|
240
261
|
assertNativeLabel(resolvedLabel);
|
|
241
262
|
const meta = readMeta();
|
|
242
263
|
const matches = nativeIdentityRows(meta, agent, identityKey);
|
|
243
|
-
assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
|
|
264
|
+
assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)), agent);
|
|
244
265
|
if (matches.length === 0)
|
|
245
266
|
return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
|
|
246
267
|
// Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
|
|
@@ -25,10 +25,15 @@ export declare function resolveTcpEndpoint(): {
|
|
|
25
25
|
port: number;
|
|
26
26
|
token: string | null;
|
|
27
27
|
} | null;
|
|
28
|
+
export declare function resolveVncEndpoint(): {
|
|
29
|
+
host: string;
|
|
30
|
+
port: number;
|
|
31
|
+
password: string;
|
|
32
|
+
} | null;
|
|
28
33
|
export declare function openComputerClient(): ComputerClient;
|
|
29
34
|
export declare const RPC_TIMEOUT_MS = 30000;
|
|
30
35
|
export declare function resolveRpcTimeoutMs(env: string | undefined): number;
|
|
31
36
|
export declare function describeTransport(): {
|
|
32
|
-
kind: 'socket' | 'stdio' | 'tcp' | 'none';
|
|
37
|
+
kind: 'socket' | 'stdio' | 'tcp' | 'vnc' | 'none';
|
|
33
38
|
path: string | null;
|
|
34
39
|
};
|
|
@@ -17,6 +17,7 @@ import * as fs from 'fs';
|
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
19
|
import { getHelpersDir, getLogsDir, getUserPermissionsDir, getPermissionsDir } from '../state.js';
|
|
20
|
+
import { RfbClient, parseVncEndpoint } from './rfb-client.js';
|
|
20
21
|
// Resolve the socket path used by the launchd-managed daemon. Internal
|
|
21
22
|
// scratch — lives under getHelpersDir() (~/.agents/.cache/helpers/),
|
|
22
23
|
// matching browser.sock.
|
|
@@ -209,11 +210,28 @@ export function resolveTcpEndpoint() {
|
|
|
209
210
|
const token = process.env.COMPUTER_HELPER_TOKEN;
|
|
210
211
|
return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
|
|
211
212
|
}
|
|
213
|
+
// Resolve the VNC/RFB endpoint for driving a remote GUI desktop over the RFB
|
|
214
|
+
// protocol (an x11vnc/Xvnc server — e.g. a headless Linux desktop or an LXD
|
|
215
|
+
// container exposing x11vnc on the host's Tailscale IP). COMPUTER_HELPER_VNC is
|
|
216
|
+
// "host:port" (port defaults to 5901); COMPUTER_HELPER_VNC_PASSWORD is the VNC
|
|
217
|
+
// password. Unlike the native helpers this transport carries a framebuffer, not
|
|
218
|
+
// an accessibility tree, so it is coordinate-based (see rfb-client.ts).
|
|
219
|
+
export function resolveVncEndpoint() {
|
|
220
|
+
const parsed = parseVncEndpoint(process.env.COMPUTER_HELPER_VNC);
|
|
221
|
+
if (!parsed)
|
|
222
|
+
return null;
|
|
223
|
+
return { ...parsed, password: process.env.COMPUTER_HELPER_VNC_PASSWORD ?? '' };
|
|
224
|
+
}
|
|
212
225
|
// Pick the best transport. Precedence:
|
|
213
|
-
// 1.
|
|
214
|
-
// 2. the
|
|
215
|
-
// 3.
|
|
226
|
+
// 1. COMPUTER_HELPER_VNC -> an RFB/VNC desktop (Linux GUI over the wire).
|
|
227
|
+
// 2. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
|
|
228
|
+
// 3. the macOS launchd socket if it exists.
|
|
229
|
+
// 4. spawning the helper as a subprocess (legacy/dev fallback).
|
|
216
230
|
export function openComputerClient() {
|
|
231
|
+
const vnc = resolveVncEndpoint();
|
|
232
|
+
if (vnc) {
|
|
233
|
+
return new RfbClient(vnc.host, vnc.port, vnc.password);
|
|
234
|
+
}
|
|
217
235
|
const tcp = resolveTcpEndpoint();
|
|
218
236
|
if (tcp) {
|
|
219
237
|
return new TcpClient(tcp.host, tcp.port, tcp.token);
|
|
@@ -415,6 +433,8 @@ class StdioClient extends BaseClient {
|
|
|
415
433
|
// .app) must not be told "no transport". `path` is null: the endpoint is an
|
|
416
434
|
// env-configured host:port, not an on-disk path.
|
|
417
435
|
export function describeTransport() {
|
|
436
|
+
if (resolveVncEndpoint())
|
|
437
|
+
return { kind: 'vnc', path: null };
|
|
418
438
|
if (resolveTcpEndpoint())
|
|
419
439
|
return { kind: 'tcp', path: null };
|
|
420
440
|
const sockPath = resolveSocketPath();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function desEncryptBlock(key8: Buffer, block8: Buffer): Buffer;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Minimal, dependency-free single-block DES (ECB) — used only for VNC
|
|
2
|
+
// authentication's challenge/response. Node 24 ships OpenSSL 3, whose DES lives
|
|
3
|
+
// in the legacy provider that is off by default, so `createCipheriv('des-ecb')`
|
|
4
|
+
// throws `digital envelope routines::unsupported`. VNC clients have always
|
|
5
|
+
// bundled their own DES for exactly this reason. This is a textbook FIPS-46 DES
|
|
6
|
+
// implementation over bit arrays — verified against the standard test vector in
|
|
7
|
+
// des.test.ts. It encrypts one 8-byte block; VNC auth encrypts the two halves
|
|
8
|
+
// of the 16-byte challenge independently (see rfb-client.ts).
|
|
9
|
+
const IP = [
|
|
10
|
+
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
|
|
11
|
+
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
|
|
12
|
+
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
|
|
13
|
+
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
|
|
14
|
+
];
|
|
15
|
+
const FP = [
|
|
16
|
+
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
|
|
17
|
+
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
|
|
18
|
+
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
|
|
19
|
+
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
|
|
20
|
+
];
|
|
21
|
+
const E = [
|
|
22
|
+
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17,
|
|
23
|
+
16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
|
|
24
|
+
];
|
|
25
|
+
const P = [
|
|
26
|
+
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
|
|
27
|
+
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
|
|
28
|
+
];
|
|
29
|
+
const PC1 = [
|
|
30
|
+
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
|
|
31
|
+
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
|
|
32
|
+
];
|
|
33
|
+
const PC2 = [
|
|
34
|
+
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
|
|
35
|
+
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
|
|
36
|
+
];
|
|
37
|
+
const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
|
|
38
|
+
const SBOX = [
|
|
39
|
+
[14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
|
|
40
|
+
[15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
|
|
41
|
+
[10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
|
|
42
|
+
[7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
|
|
43
|
+
[2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
|
|
44
|
+
[12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
|
|
45
|
+
[4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
|
|
46
|
+
[13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
|
|
47
|
+
];
|
|
48
|
+
function bytesToBits(buf) {
|
|
49
|
+
const bits = [];
|
|
50
|
+
for (let i = 0; i < buf.length; i++) {
|
|
51
|
+
for (let b = 7; b >= 0; b--)
|
|
52
|
+
bits.push((buf[i] >> b) & 1);
|
|
53
|
+
}
|
|
54
|
+
return bits;
|
|
55
|
+
}
|
|
56
|
+
function bitsToBytes(bits) {
|
|
57
|
+
const out = Buffer.alloc(bits.length / 8);
|
|
58
|
+
for (let i = 0; i < out.length; i++) {
|
|
59
|
+
let byte = 0;
|
|
60
|
+
for (let b = 0; b < 8; b++)
|
|
61
|
+
byte = (byte << 1) | bits[i * 8 + b];
|
|
62
|
+
out[i] = byte;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function permute(bits, table) {
|
|
67
|
+
return table.map((pos) => bits[pos - 1]);
|
|
68
|
+
}
|
|
69
|
+
function keySchedule(key8) {
|
|
70
|
+
const keyBits = bytesToBits(key8);
|
|
71
|
+
const pc1 = permute(keyBits, PC1);
|
|
72
|
+
let c = pc1.slice(0, 28);
|
|
73
|
+
let d = pc1.slice(28, 56);
|
|
74
|
+
const subkeys = [];
|
|
75
|
+
for (let round = 0; round < 16; round++) {
|
|
76
|
+
const s = SHIFTS[round];
|
|
77
|
+
c = c.slice(s).concat(c.slice(0, s));
|
|
78
|
+
d = d.slice(s).concat(d.slice(0, s));
|
|
79
|
+
subkeys.push(permute(c.concat(d), PC2));
|
|
80
|
+
}
|
|
81
|
+
return subkeys;
|
|
82
|
+
}
|
|
83
|
+
function feistel(r, subkey) {
|
|
84
|
+
const expanded = permute(r, E);
|
|
85
|
+
const xored = expanded.map((bit, i) => bit ^ subkey[i]);
|
|
86
|
+
const out = [];
|
|
87
|
+
for (let box = 0; box < 8; box++) {
|
|
88
|
+
const chunk = xored.slice(box * 6, box * 6 + 6);
|
|
89
|
+
const row = (chunk[0] << 1) | chunk[5];
|
|
90
|
+
const col = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4];
|
|
91
|
+
const val = SBOX[box][row * 16 + col];
|
|
92
|
+
for (let b = 3; b >= 0; b--)
|
|
93
|
+
out.push((val >> b) & 1);
|
|
94
|
+
}
|
|
95
|
+
return permute(out, P);
|
|
96
|
+
}
|
|
97
|
+
// Encrypt one 8-byte block with DES-ECB under an 8-byte key.
|
|
98
|
+
export function desEncryptBlock(key8, block8) {
|
|
99
|
+
if (key8.length !== 8)
|
|
100
|
+
throw new Error(`des key must be 8 bytes, got ${key8.length}`);
|
|
101
|
+
if (block8.length !== 8)
|
|
102
|
+
throw new Error(`des block must be 8 bytes, got ${block8.length}`);
|
|
103
|
+
const subkeys = keySchedule(key8);
|
|
104
|
+
const bits = permute(bytesToBits(block8), IP);
|
|
105
|
+
let l = bits.slice(0, 32);
|
|
106
|
+
let r = bits.slice(32, 64);
|
|
107
|
+
for (let round = 0; round < 16; round++) {
|
|
108
|
+
const f = feistel(r, subkeys[round]);
|
|
109
|
+
const next = l.map((bit, i) => bit ^ f[i]);
|
|
110
|
+
l = r;
|
|
111
|
+
r = next;
|
|
112
|
+
}
|
|
113
|
+
return bitsToBytes(permute(r.concat(l), FP));
|
|
114
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ComputerClient, RPCResponse } from './computer-rpc.js';
|
|
2
|
+
export declare function mirrorByte(b: number): number;
|
|
3
|
+
export declare function vncAuthResponse(challenge: Buffer, password: string): Buffer;
|
|
4
|
+
export declare function buildPixelFormat(): Buffer;
|
|
5
|
+
export declare function encodeSetPixelFormat(pf: Buffer): Buffer;
|
|
6
|
+
export declare function encodeSetEncodings(encodings: number[]): Buffer;
|
|
7
|
+
export declare function encodeFbUpdateRequest(incremental: boolean, x: number, y: number, w: number, h: number): Buffer;
|
|
8
|
+
export declare function encodePointerEvent(buttonMask: number, x: number, y: number): Buffer;
|
|
9
|
+
export declare function encodeKeyEvent(down: boolean, keysym: number): Buffer;
|
|
10
|
+
export declare function keysymForChar(ch: string): number;
|
|
11
|
+
export declare function keysymForKeyName(name: string): number | null;
|
|
12
|
+
export declare function parseKeyCombo(combo: string): {
|
|
13
|
+
modifiers: number[];
|
|
14
|
+
key: number;
|
|
15
|
+
};
|
|
16
|
+
export declare function encodePng(rgb: Buffer, width: number, height: number): Buffer;
|
|
17
|
+
export interface ServerInit {
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function scrollButtonsFor(dx: number, dy: number): {
|
|
23
|
+
vButton: number;
|
|
24
|
+
hButton: number;
|
|
25
|
+
};
|
|
26
|
+
export declare const MAX_RFB_STRING_LEN: number;
|
|
27
|
+
export declare function parseVncEndpoint(raw: string | undefined): {
|
|
28
|
+
host: string;
|
|
29
|
+
port: number;
|
|
30
|
+
} | null;
|
|
31
|
+
export declare class RfbClient implements ComputerClient {
|
|
32
|
+
private host;
|
|
33
|
+
private port;
|
|
34
|
+
private password;
|
|
35
|
+
private sock;
|
|
36
|
+
private reader;
|
|
37
|
+
private ready;
|
|
38
|
+
private info;
|
|
39
|
+
private closed;
|
|
40
|
+
constructor(host: string, port: number, password: string);
|
|
41
|
+
private write;
|
|
42
|
+
private readLenString;
|
|
43
|
+
private handshake;
|
|
44
|
+
private teardown;
|
|
45
|
+
private withTimeout;
|
|
46
|
+
private captureScreen;
|
|
47
|
+
private sendClick;
|
|
48
|
+
private sendScroll;
|
|
49
|
+
private typeText;
|
|
50
|
+
private pressCombo;
|
|
51
|
+
call(method: string, params?: Record<string, unknown>): Promise<RPCResponse>;
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// RFB (VNC) transport for `agents computer` — drive a remote GUI desktop over
|
|
2
|
+
// the RFB protocol instead of a native Accessibility/UIA daemon.
|
|
3
|
+
//
|
|
4
|
+
// This is the third computer backend, sitting behind the same ComputerClient
|
|
5
|
+
// interface as the macOS Unix-socket helper and the Windows TCP helper. It
|
|
6
|
+
// speaks RFB 3.8 directly to an x11vnc/Xvnc server (e.g. a headless Linux
|
|
7
|
+
// desktop, or an LXD container exposing x11vnc on the host's Tailscale IP), so
|
|
8
|
+
// no per-box native helper install is needed — the display is driven over the
|
|
9
|
+
// same VNC stream a human would watch.
|
|
10
|
+
//
|
|
11
|
+
// It is COORDINATE-BASED: RFB carries a framebuffer and pointer/key events, not
|
|
12
|
+
// an accessibility tree. So it implements the observe verb `screenshot` and the
|
|
13
|
+
// interact verbs `click`/`right-click`/`type`/`type-text`/`key`/`scroll`/`wait`;
|
|
14
|
+
// the AX-tree verbs (`describe`/`get-text`/`ax-action`/element-id targeting) and
|
|
15
|
+
// app lifecycle (`launch`/`apps` beyond the synthetic desktop target) have no RFB
|
|
16
|
+
// equivalent and FAIL LOUD with a clear message rather than a silent no-op.
|
|
17
|
+
//
|
|
18
|
+
// Wire format note: unlike the JSON-RPC transports, there is no line-delimited
|
|
19
|
+
// RPC on the wire here — `call(method, params)` translates a computer RPC verb
|
|
20
|
+
// into RFB protocol messages and shapes the reply to match what the command
|
|
21
|
+
// layer expects from the native helpers (e.g. screenshot -> { image_data, width,
|
|
22
|
+
// height }).
|
|
23
|
+
import { createConnection } from 'net';
|
|
24
|
+
import { deflateSync, crc32 } from 'zlib';
|
|
25
|
+
import { desEncryptBlock } from './des.js';
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Pure protocol helpers (exported for unit tests — no socket needed)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// VNC DES auth mangles the key by reversing the bit order of each byte. This is
|
|
30
|
+
// a historical quirk of the reference implementation, not real DES.
|
|
31
|
+
export function mirrorByte(b) {
|
|
32
|
+
let r = 0;
|
|
33
|
+
for (let i = 0; i < 8; i++)
|
|
34
|
+
r |= ((b >> i) & 1) << (7 - i);
|
|
35
|
+
return r & 0xff;
|
|
36
|
+
}
|
|
37
|
+
// Compute the 16-byte VNC-auth response: DES-ECB encrypt the server's 16-byte
|
|
38
|
+
// challenge with the (bit-mirrored, 8-byte, null-padded) password as the key.
|
|
39
|
+
export function vncAuthResponse(challenge, password) {
|
|
40
|
+
if (challenge.length !== 16)
|
|
41
|
+
throw new Error(`vnc challenge must be 16 bytes, got ${challenge.length}`);
|
|
42
|
+
const key = Buffer.alloc(8, 0);
|
|
43
|
+
const pw = Buffer.from(password, 'latin1');
|
|
44
|
+
for (let i = 0; i < 8 && i < pw.length; i++)
|
|
45
|
+
key[i] = mirrorByte(pw[i]);
|
|
46
|
+
// VNC encrypts the two 8-byte halves of the challenge as independent ECB
|
|
47
|
+
// blocks under the same key.
|
|
48
|
+
return Buffer.concat([
|
|
49
|
+
desEncryptBlock(key, challenge.subarray(0, 8)),
|
|
50
|
+
desEncryptBlock(key, challenge.subarray(8, 16)),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
// 32bpp true-color, little-endian, R@16 G@8 B@0 — so a pixel's 4 wire bytes are
|
|
54
|
+
// [B, G, R, x] and RGB extraction is buf[i+2], buf[i+1], buf[i].
|
|
55
|
+
export function buildPixelFormat() {
|
|
56
|
+
const pf = Buffer.alloc(16, 0);
|
|
57
|
+
pf[0] = 32; // bits-per-pixel
|
|
58
|
+
pf[1] = 24; // depth
|
|
59
|
+
pf[2] = 0; // big-endian-flag (little-endian)
|
|
60
|
+
pf[3] = 1; // true-color-flag
|
|
61
|
+
pf.writeUInt16BE(255, 4); // red-max
|
|
62
|
+
pf.writeUInt16BE(255, 6); // green-max
|
|
63
|
+
pf.writeUInt16BE(255, 8); // blue-max
|
|
64
|
+
pf[10] = 16; // red-shift
|
|
65
|
+
pf[11] = 8; // green-shift
|
|
66
|
+
pf[12] = 0; // blue-shift
|
|
67
|
+
return pf;
|
|
68
|
+
}
|
|
69
|
+
export function encodeSetPixelFormat(pf) {
|
|
70
|
+
const msg = Buffer.alloc(4 + 16);
|
|
71
|
+
msg[0] = 0; // SetPixelFormat
|
|
72
|
+
pf.copy(msg, 4);
|
|
73
|
+
return msg;
|
|
74
|
+
}
|
|
75
|
+
export function encodeSetEncodings(encodings) {
|
|
76
|
+
const msg = Buffer.alloc(4 + encodings.length * 4);
|
|
77
|
+
msg[0] = 2; // SetEncodings
|
|
78
|
+
msg.writeUInt16BE(encodings.length, 2);
|
|
79
|
+
encodings.forEach((e, i) => msg.writeInt32BE(e, 4 + i * 4));
|
|
80
|
+
return msg;
|
|
81
|
+
}
|
|
82
|
+
export function encodeFbUpdateRequest(incremental, x, y, w, h) {
|
|
83
|
+
const msg = Buffer.alloc(10);
|
|
84
|
+
msg[0] = 3; // FramebufferUpdateRequest
|
|
85
|
+
msg[1] = incremental ? 1 : 0;
|
|
86
|
+
msg.writeUInt16BE(x, 2);
|
|
87
|
+
msg.writeUInt16BE(y, 4);
|
|
88
|
+
msg.writeUInt16BE(w, 6);
|
|
89
|
+
msg.writeUInt16BE(h, 8);
|
|
90
|
+
return msg;
|
|
91
|
+
}
|
|
92
|
+
export function encodePointerEvent(buttonMask, x, y) {
|
|
93
|
+
const msg = Buffer.alloc(6);
|
|
94
|
+
msg[0] = 5; // PointerEvent
|
|
95
|
+
msg[1] = buttonMask & 0xff;
|
|
96
|
+
msg.writeUInt16BE(Math.max(0, Math.round(x)), 2);
|
|
97
|
+
msg.writeUInt16BE(Math.max(0, Math.round(y)), 4);
|
|
98
|
+
return msg;
|
|
99
|
+
}
|
|
100
|
+
export function encodeKeyEvent(down, keysym) {
|
|
101
|
+
const msg = Buffer.alloc(8);
|
|
102
|
+
msg[0] = 4; // KeyEvent
|
|
103
|
+
msg[1] = down ? 1 : 0;
|
|
104
|
+
msg.writeUInt32BE(keysym >>> 0, 4);
|
|
105
|
+
return msg;
|
|
106
|
+
}
|
|
107
|
+
// X11 keysyms for the named keys an agent actually presses.
|
|
108
|
+
const NAMED_KEYSYMS = {
|
|
109
|
+
return: 0xff0d, enter: 0xff0d, tab: 0xff09, escape: 0xff1b, esc: 0xff1b,
|
|
110
|
+
backspace: 0xff08, delete: 0xffff, del: 0xffff, space: 0x0020,
|
|
111
|
+
home: 0xff50, end: 0xff57, pageup: 0xff55, pagedown: 0xff56,
|
|
112
|
+
left: 0xff51, up: 0xff52, right: 0xff53, down: 0xff54, insert: 0xff63,
|
|
113
|
+
f1: 0xffbe, f2: 0xffbf, f3: 0xffc0, f4: 0xffc1, f5: 0xffc2, f6: 0xffc3,
|
|
114
|
+
f7: 0xffc4, f8: 0xffc5, f9: 0xffc6, f10: 0xffc7, f11: 0xffc8, f12: 0xffc9,
|
|
115
|
+
};
|
|
116
|
+
const MODIFIER_KEYSYMS = {
|
|
117
|
+
shift: 0xffe1, ctrl: 0xffe3, control: 0xffe3,
|
|
118
|
+
alt: 0xffe9, option: 0xffe9, opt: 0xffe9,
|
|
119
|
+
cmd: 0xffeb, command: 0xffeb, super: 0xffeb, meta: 0xffeb, win: 0xffeb,
|
|
120
|
+
};
|
|
121
|
+
// A single printable character -> its keysym. Latin-1 maps 1:1; anything else
|
|
122
|
+
// uses the Unicode-plane keysym (0x01000000 + codepoint).
|
|
123
|
+
export function keysymForChar(ch) {
|
|
124
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
125
|
+
if (cp >= 0x20 && cp <= 0xff)
|
|
126
|
+
return cp;
|
|
127
|
+
return 0x01000000 + cp;
|
|
128
|
+
}
|
|
129
|
+
export function keysymForKeyName(name) {
|
|
130
|
+
const k = name.trim().toLowerCase();
|
|
131
|
+
if (k in NAMED_KEYSYMS)
|
|
132
|
+
return NAMED_KEYSYMS[k];
|
|
133
|
+
if ([...name].length === 1)
|
|
134
|
+
return keysymForChar(name);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Parse "ctrl+a" / "cmd+shift+t" / "Return" into modifier keysyms + a key keysym.
|
|
138
|
+
export function parseKeyCombo(combo) {
|
|
139
|
+
const parts = combo.split('+').map((p) => p.trim()).filter(Boolean);
|
|
140
|
+
if (parts.length === 0)
|
|
141
|
+
throw new Error(`empty key combo: "${combo}"`);
|
|
142
|
+
const keyPart = parts[parts.length - 1];
|
|
143
|
+
const modParts = parts.slice(0, -1);
|
|
144
|
+
const modifiers = [];
|
|
145
|
+
for (const m of modParts) {
|
|
146
|
+
const ks = MODIFIER_KEYSYMS[m.toLowerCase()];
|
|
147
|
+
if (ks == null)
|
|
148
|
+
throw new Error(`unknown modifier "${m}" in "${combo}"`);
|
|
149
|
+
modifiers.push(ks);
|
|
150
|
+
}
|
|
151
|
+
const key = keysymForKeyName(keyPart);
|
|
152
|
+
if (key == null)
|
|
153
|
+
throw new Error(`unknown key "${keyPart}" in "${combo}"`);
|
|
154
|
+
return { modifiers, key };
|
|
155
|
+
}
|
|
156
|
+
// Minimal PNG encoder (color-type 2, RGB, 8-bit, no interlace). rgb is
|
|
157
|
+
// width*height*3 bytes, row-major. Uses zlib for IDAT and zlib.crc32 for chunk
|
|
158
|
+
// CRCs — no external dependency, deterministic, unit-testable.
|
|
159
|
+
export function encodePng(rgb, width, height) {
|
|
160
|
+
const stride = width * 3;
|
|
161
|
+
// Prepend a per-scanline filter byte (0 = none).
|
|
162
|
+
const raw = Buffer.alloc(height * (stride + 1));
|
|
163
|
+
for (let y = 0; y < height; y++) {
|
|
164
|
+
raw[y * (stride + 1)] = 0;
|
|
165
|
+
rgb.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
166
|
+
}
|
|
167
|
+
const idat = deflateSync(raw);
|
|
168
|
+
const chunk = (type, data) => {
|
|
169
|
+
const len = Buffer.alloc(4);
|
|
170
|
+
len.writeUInt32BE(data.length, 0);
|
|
171
|
+
const typeBuf = Buffer.from(type, 'latin1');
|
|
172
|
+
const crc = Buffer.alloc(4);
|
|
173
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])) >>> 0, 0);
|
|
174
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
175
|
+
};
|
|
176
|
+
const ihdr = Buffer.alloc(13);
|
|
177
|
+
ihdr.writeUInt32BE(width, 0);
|
|
178
|
+
ihdr.writeUInt32BE(height, 4);
|
|
179
|
+
ihdr[8] = 8; // bit depth
|
|
180
|
+
ihdr[9] = 2; // color type RGB
|
|
181
|
+
ihdr[10] = 0; // compression
|
|
182
|
+
ihdr[11] = 0; // filter
|
|
183
|
+
ihdr[12] = 0; // interlace
|
|
184
|
+
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
185
|
+
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
|
|
186
|
+
}
|
|
187
|
+
// Map scroll deltas to VNC wheel buttons. The `--dy` contract is "negative =
|
|
188
|
+
// down" (honored by the macOS CGEvent and Windows MOUSEEVENTF_WHEEL backends by
|
|
189
|
+
// passing dy straight through); `--dx` negative = left. RFB/X11 wheel buttons:
|
|
190
|
+
// 4 = up, 5 = down, 6 = left, 7 = right. Pure + unit-tested so the direction
|
|
191
|
+
// contract can't silently invert (it did, pre-review).
|
|
192
|
+
export function scrollButtonsFor(dx, dy) {
|
|
193
|
+
return {
|
|
194
|
+
vButton: dy < 0 ? 5 : dy > 0 ? 4 : 0,
|
|
195
|
+
hButton: dx < 0 ? 6 : dx > 0 ? 7 : 0,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// Guard on any length-prefixed string the server frames (name, failure reason,
|
|
199
|
+
// ServerCutText). A broken/hostile server sending a huge length would otherwise
|
|
200
|
+
// make us wait indefinitely for bytes that never come; fail loud instead.
|
|
201
|
+
export const MAX_RFB_STRING_LEN = 1 << 20; // 1 MiB
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Async socket reader — serve exact-length reads off a streamed connection.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
class ByteReader {
|
|
206
|
+
buf = Buffer.alloc(0);
|
|
207
|
+
want = null;
|
|
208
|
+
error = null;
|
|
209
|
+
push(chunk) {
|
|
210
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
211
|
+
this.pump();
|
|
212
|
+
}
|
|
213
|
+
fail(err) {
|
|
214
|
+
this.error = err;
|
|
215
|
+
if (this.want) {
|
|
216
|
+
const w = this.want;
|
|
217
|
+
this.want = null;
|
|
218
|
+
// Reject via a sentinel-length read; the awaiting read() rejects below.
|
|
219
|
+
w.resolve(Buffer.alloc(0));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
pump() {
|
|
223
|
+
if (this.want && this.buf.length >= this.want.n) {
|
|
224
|
+
const { n, resolve } = this.want;
|
|
225
|
+
this.want = null;
|
|
226
|
+
const out = this.buf.subarray(0, n);
|
|
227
|
+
this.buf = this.buf.subarray(n);
|
|
228
|
+
resolve(out);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
read(n) {
|
|
232
|
+
if (this.error)
|
|
233
|
+
return Promise.reject(this.error);
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
this.want = {
|
|
236
|
+
n,
|
|
237
|
+
resolve: (b) => {
|
|
238
|
+
if (this.error)
|
|
239
|
+
reject(this.error);
|
|
240
|
+
else
|
|
241
|
+
resolve(b);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
this.pump();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function parseVncEndpoint(raw) {
|
|
249
|
+
if (!raw || raw.length === 0)
|
|
250
|
+
return null;
|
|
251
|
+
const idx = raw.lastIndexOf(':');
|
|
252
|
+
const host = idx >= 0 ? raw.slice(0, idx) : raw;
|
|
253
|
+
const portStr = idx >= 0 ? raw.slice(idx + 1) : '5901';
|
|
254
|
+
const port = Number(portStr);
|
|
255
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
256
|
+
return null;
|
|
257
|
+
return { host: host || '127.0.0.1', port };
|
|
258
|
+
}
|
|
259
|
+
const RPC_TIMEOUT_MS = 30_000;
|
|
260
|
+
// The one synthetic target the VNC backend exposes: the whole desktop. Lets the
|
|
261
|
+
// screenshot command's list_apps->pickTarget->pid flow resolve without change
|
|
262
|
+
// (`active: true` is what pickTarget's frontmost-app default selects).
|
|
263
|
+
const DESKTOP_APP = { pid: 1, bundle_id: 'desktop', name: 'Desktop', active: true };
|
|
264
|
+
export class RfbClient {
|
|
265
|
+
host;
|
|
266
|
+
port;
|
|
267
|
+
password;
|
|
268
|
+
sock = null;
|
|
269
|
+
reader = new ByteReader();
|
|
270
|
+
ready;
|
|
271
|
+
info = null;
|
|
272
|
+
closed = false;
|
|
273
|
+
constructor(host, port, password) {
|
|
274
|
+
this.host = host;
|
|
275
|
+
this.port = port;
|
|
276
|
+
this.password = password;
|
|
277
|
+
this.ready = this.handshake();
|
|
278
|
+
}
|
|
279
|
+
write(b) {
|
|
280
|
+
if (!this.sock)
|
|
281
|
+
throw new Error('vnc socket not connected');
|
|
282
|
+
this.sock.write(b);
|
|
283
|
+
}
|
|
284
|
+
// Read a U32-length-prefixed latin1 string, capped so a bogus length can't
|
|
285
|
+
// wedge us waiting for bytes that never arrive.
|
|
286
|
+
async readLenString(what) {
|
|
287
|
+
const len = (await this.reader.read(4)).readUInt32BE(0);
|
|
288
|
+
if (len > MAX_RFB_STRING_LEN)
|
|
289
|
+
throw new Error(`vnc ${what} length ${len} exceeds ${MAX_RFB_STRING_LEN}`);
|
|
290
|
+
return (await this.reader.read(len)).toString('latin1');
|
|
291
|
+
}
|
|
292
|
+
async handshake() {
|
|
293
|
+
await new Promise((resolve, reject) => {
|
|
294
|
+
const sock = createConnection({ host: this.host, port: this.port }, () => resolve());
|
|
295
|
+
sock.on('data', (c) => this.reader.push(c));
|
|
296
|
+
sock.on('error', (e) => {
|
|
297
|
+
this.closed = true;
|
|
298
|
+
this.reader.fail(e);
|
|
299
|
+
reject(e);
|
|
300
|
+
});
|
|
301
|
+
sock.on('close', () => {
|
|
302
|
+
this.closed = true;
|
|
303
|
+
this.reader.fail(new Error('vnc connection closed'));
|
|
304
|
+
});
|
|
305
|
+
this.sock = sock;
|
|
306
|
+
});
|
|
307
|
+
// 1. ProtocolVersion: server sends "RFB 003.00x\n", we answer 3.8.
|
|
308
|
+
const serverVersion = (await this.reader.read(12)).toString('latin1');
|
|
309
|
+
if (!serverVersion.startsWith('RFB '))
|
|
310
|
+
throw new Error(`not an RFB server (got "${serverVersion.trim()}")`);
|
|
311
|
+
this.write(Buffer.from('RFB 003.008\n', 'latin1'));
|
|
312
|
+
// 2. Security types (3.7+): count, then that many U8 types.
|
|
313
|
+
const nTypes = (await this.reader.read(1))[0];
|
|
314
|
+
if (nTypes === 0) {
|
|
315
|
+
const reason = await this.readLenString('failure reason');
|
|
316
|
+
throw new Error(`vnc server refused connection: ${reason}`);
|
|
317
|
+
}
|
|
318
|
+
const types = [...(await this.reader.read(nTypes))];
|
|
319
|
+
// Prefer VNC auth (2); accept None (1).
|
|
320
|
+
let chosen;
|
|
321
|
+
if (types.includes(2))
|
|
322
|
+
chosen = 2;
|
|
323
|
+
else if (types.includes(1))
|
|
324
|
+
chosen = 1;
|
|
325
|
+
else
|
|
326
|
+
throw new Error(`no supported vnc security type (server offered ${types.join(',')})`);
|
|
327
|
+
this.write(Buffer.from([chosen]));
|
|
328
|
+
if (chosen === 2) {
|
|
329
|
+
const challenge = await this.reader.read(16);
|
|
330
|
+
if (!this.password)
|
|
331
|
+
throw new Error('vnc server requires a password; set COMPUTER_HELPER_VNC_PASSWORD or --vnc-password');
|
|
332
|
+
const response = vncAuthResponse(challenge, this.password);
|
|
333
|
+
this.write(response);
|
|
334
|
+
}
|
|
335
|
+
// 3. SecurityResult (always present in 3.8).
|
|
336
|
+
const result = (await this.reader.read(4)).readUInt32BE(0);
|
|
337
|
+
if (result !== 0) {
|
|
338
|
+
// 3.8 sends a reason string on failure.
|
|
339
|
+
let reason = 'authentication failed';
|
|
340
|
+
try {
|
|
341
|
+
reason = await this.readLenString('auth failure reason');
|
|
342
|
+
}
|
|
343
|
+
catch { /* some servers just drop the connection instead */ }
|
|
344
|
+
throw new Error(`vnc auth failed: ${reason}`);
|
|
345
|
+
}
|
|
346
|
+
// 4. ClientInit (shared = 1) -> ServerInit.
|
|
347
|
+
this.write(Buffer.from([1]));
|
|
348
|
+
const head = await this.reader.read(4 + 16 + 4);
|
|
349
|
+
const width = head.readUInt16BE(0);
|
|
350
|
+
const height = head.readUInt16BE(2);
|
|
351
|
+
const nameLen = head.readUInt32BE(20);
|
|
352
|
+
if (nameLen > MAX_RFB_STRING_LEN)
|
|
353
|
+
throw new Error(`vnc desktop name length ${nameLen} exceeds ${MAX_RFB_STRING_LEN}`);
|
|
354
|
+
const name = (await this.reader.read(nameLen)).toString('latin1');
|
|
355
|
+
// Pin our pixel format + advertise only Raw so decode is simple + correct.
|
|
356
|
+
this.write(encodeSetPixelFormat(buildPixelFormat()));
|
|
357
|
+
this.write(encodeSetEncodings([0]));
|
|
358
|
+
this.info = { width, height, name };
|
|
359
|
+
return this.info;
|
|
360
|
+
}
|
|
361
|
+
// Tear the connection down for good: reject any pending read, destroy the
|
|
362
|
+
// socket, and mark closed so every later call fails loud. Idempotent.
|
|
363
|
+
teardown(message) {
|
|
364
|
+
if (this.closed)
|
|
365
|
+
return;
|
|
366
|
+
this.closed = true;
|
|
367
|
+
this.reader.fail(new Error(message));
|
|
368
|
+
this.sock?.destroy();
|
|
369
|
+
}
|
|
370
|
+
// A timed-out RFB read cannot be recovered: the awaited bytes may still land
|
|
371
|
+
// later and would be misread as the NEXT request's reply, permanently
|
|
372
|
+
// desyncing the stream. So a timeout tears the whole connection down rather
|
|
373
|
+
// than leaving a pending read for the next call to silently overwrite.
|
|
374
|
+
withTimeout(p, label) {
|
|
375
|
+
return new Promise((resolve, reject) => {
|
|
376
|
+
const timer = setTimeout(() => {
|
|
377
|
+
const msg = `vnc ${label} timed out after ${RPC_TIMEOUT_MS}ms`;
|
|
378
|
+
this.teardown(msg);
|
|
379
|
+
reject(new Error(msg));
|
|
380
|
+
}, RPC_TIMEOUT_MS);
|
|
381
|
+
p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
// Request the full framebuffer and assemble it into an RGB buffer, then PNG.
|
|
385
|
+
async captureScreen() {
|
|
386
|
+
const info = this.info;
|
|
387
|
+
this.write(encodeFbUpdateRequest(false, 0, 0, info.width, info.height));
|
|
388
|
+
const rgb = Buffer.alloc(info.width * info.height * 3);
|
|
389
|
+
// Read messages until a FramebufferUpdate covering the frame is assembled.
|
|
390
|
+
// We requested a single non-incremental full-frame update, so one
|
|
391
|
+
// FramebufferUpdate message carries all rectangles.
|
|
392
|
+
for (;;) {
|
|
393
|
+
const msgType = (await this.reader.read(1))[0];
|
|
394
|
+
if (msgType === 0) {
|
|
395
|
+
await this.reader.read(1); // padding
|
|
396
|
+
const nRects = (await this.reader.read(2)).readUInt16BE(0);
|
|
397
|
+
for (let r = 0; r < nRects; r++) {
|
|
398
|
+
const rh = await this.reader.read(12);
|
|
399
|
+
const rx = rh.readUInt16BE(0);
|
|
400
|
+
const ry = rh.readUInt16BE(2);
|
|
401
|
+
const rw = rh.readUInt16BE(4);
|
|
402
|
+
const rHt = rh.readUInt16BE(6);
|
|
403
|
+
const enc = rh.readInt32BE(8);
|
|
404
|
+
if (enc !== 0)
|
|
405
|
+
throw new Error(`vnc server used unsupported encoding ${enc} (only Raw was advertised)`);
|
|
406
|
+
const pixels = await this.reader.read(rw * rHt * 4);
|
|
407
|
+
for (let y = 0; y < rHt; y++) {
|
|
408
|
+
for (let x = 0; x < rw; x++) {
|
|
409
|
+
const src = (y * rw + x) * 4;
|
|
410
|
+
const dstX = rx + x;
|
|
411
|
+
const dstY = ry + y;
|
|
412
|
+
if (dstX >= info.width || dstY >= info.height)
|
|
413
|
+
continue;
|
|
414
|
+
const dst = (dstY * info.width + dstX) * 3;
|
|
415
|
+
rgb[dst] = pixels[src + 2]; // R
|
|
416
|
+
rgb[dst + 1] = pixels[src + 1]; // G
|
|
417
|
+
rgb[dst + 2] = pixels[src]; // B
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
else if (msgType === 2) {
|
|
424
|
+
// Bell — no body.
|
|
425
|
+
}
|
|
426
|
+
else if (msgType === 3) {
|
|
427
|
+
// ServerCutText: 3 padding + U32 length + text (discarded).
|
|
428
|
+
await this.reader.read(3);
|
|
429
|
+
await this.readLenString('server cut text');
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
throw new Error(`unexpected vnc server message type ${msgType}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { image_data: encodePng(rgb, info.width, info.height).toString('base64'), width: info.width, height: info.height };
|
|
436
|
+
}
|
|
437
|
+
sendClick(x, y, button, count) {
|
|
438
|
+
const mask = 1 << (button - 1);
|
|
439
|
+
for (let i = 0; i < Math.max(1, count); i++) {
|
|
440
|
+
this.write(encodePointerEvent(0, x, y)); // move
|
|
441
|
+
this.write(encodePointerEvent(mask, x, y)); // press
|
|
442
|
+
this.write(encodePointerEvent(0, x, y)); // release
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
sendScroll(x, y, dx, dy, count) {
|
|
446
|
+
const notches = Math.max(1, count || Math.max(Math.abs(dx), Math.abs(dy)) || 1);
|
|
447
|
+
const { vButton, hButton } = scrollButtonsFor(dx, dy);
|
|
448
|
+
for (const button of [vButton, hButton]) {
|
|
449
|
+
if (!button)
|
|
450
|
+
continue;
|
|
451
|
+
const mask = 1 << (button - 1);
|
|
452
|
+
for (let i = 0; i < notches; i++) {
|
|
453
|
+
this.write(encodePointerEvent(mask, x, y));
|
|
454
|
+
this.write(encodePointerEvent(0, x, y));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
typeText(text) {
|
|
459
|
+
for (const ch of text) {
|
|
460
|
+
const ks = keysymForChar(ch);
|
|
461
|
+
this.write(encodeKeyEvent(true, ks));
|
|
462
|
+
this.write(encodeKeyEvent(false, ks));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
pressCombo(combo) {
|
|
466
|
+
const { modifiers, key } = parseKeyCombo(combo);
|
|
467
|
+
for (const m of modifiers)
|
|
468
|
+
this.write(encodeKeyEvent(true, m));
|
|
469
|
+
this.write(encodeKeyEvent(true, key));
|
|
470
|
+
this.write(encodeKeyEvent(false, key));
|
|
471
|
+
for (const m of [...modifiers].reverse())
|
|
472
|
+
this.write(encodeKeyEvent(false, m));
|
|
473
|
+
}
|
|
474
|
+
async call(method, params = {}) {
|
|
475
|
+
if (this.closed)
|
|
476
|
+
return { id: null, error: { code: 'vnc_closed', message: 'vnc connection closed' } };
|
|
477
|
+
try {
|
|
478
|
+
await this.withTimeout(this.ready, 'handshake');
|
|
479
|
+
const x = Number(params.x ?? 0);
|
|
480
|
+
const y = Number(params.y ?? 0);
|
|
481
|
+
switch (method) {
|
|
482
|
+
case 'list_apps':
|
|
483
|
+
return { id: null, result: { apps: [DESKTOP_APP] } };
|
|
484
|
+
case 'screenshot': {
|
|
485
|
+
if (params.list)
|
|
486
|
+
return { id: null, result: { windows: [] } };
|
|
487
|
+
const shot = await this.withTimeout(this.captureScreen(), 'screenshot');
|
|
488
|
+
return { id: null, result: shot };
|
|
489
|
+
}
|
|
490
|
+
case 'click':
|
|
491
|
+
this.sendClick(x, y, 1, Number(params.count ?? 1));
|
|
492
|
+
return { id: null, result: { ok: true } };
|
|
493
|
+
case 'right_click':
|
|
494
|
+
this.sendClick(x, y, 3, Number(params.count ?? 1));
|
|
495
|
+
return { id: null, result: { ok: true } };
|
|
496
|
+
case 'scroll':
|
|
497
|
+
this.sendScroll(x, y, Number(params.dx ?? 0), Number(params.dy ?? 0), Number(params.count ?? 0));
|
|
498
|
+
return { id: null, result: { ok: true } };
|
|
499
|
+
case 'type': {
|
|
500
|
+
if (params.x != null && params.y != null)
|
|
501
|
+
this.sendClick(x, y, 1, 1);
|
|
502
|
+
if (params.text != null)
|
|
503
|
+
this.typeText(String(params.text));
|
|
504
|
+
if (params.commit)
|
|
505
|
+
this.pressCombo('Return');
|
|
506
|
+
return { id: null, result: { ok: true } };
|
|
507
|
+
}
|
|
508
|
+
case 'type_text':
|
|
509
|
+
if (params.text != null)
|
|
510
|
+
this.typeText(String(params.text));
|
|
511
|
+
if (params.commit)
|
|
512
|
+
this.pressCombo('Return');
|
|
513
|
+
return { id: null, result: { ok: true } };
|
|
514
|
+
case 'key':
|
|
515
|
+
for (const combo of String(params.keys ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
516
|
+
this.pressCombo(combo);
|
|
517
|
+
}
|
|
518
|
+
return { id: null, result: { ok: true } };
|
|
519
|
+
case 'focus_window':
|
|
520
|
+
case 'set_focus':
|
|
521
|
+
// No window manager control over RFB; the desktop is the target.
|
|
522
|
+
return { id: null, result: { ok: true } };
|
|
523
|
+
case 'wait': {
|
|
524
|
+
const ms = Number(params.duration_ms ?? 0);
|
|
525
|
+
if (ms > 0)
|
|
526
|
+
await new Promise((r) => setTimeout(r, Math.min(ms, RPC_TIMEOUT_MS)));
|
|
527
|
+
return { id: null, result: { ok: true } };
|
|
528
|
+
}
|
|
529
|
+
case 'describe':
|
|
530
|
+
case 'get_text':
|
|
531
|
+
case 'ax_action':
|
|
532
|
+
return { id: null, error: { code: 'unsupported_over_vnc', message: `'${method}' needs an accessibility tree; the VNC backend is coordinate-based. Use screenshot + click/type by coordinate.` } };
|
|
533
|
+
case 'launch_app':
|
|
534
|
+
return { id: null, error: { code: 'unsupported_over_vnc', message: 'launch is not available over VNC; open the app from the desktop UI with click/type.' } };
|
|
535
|
+
default:
|
|
536
|
+
return { id: null, error: { code: 'unknown_method', message: `vnc backend has no method '${method}'` } };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
return { id: null, error: { code: 'vnc_error', message: e.message } };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async close() {
|
|
544
|
+
if (this.closed)
|
|
545
|
+
return;
|
|
546
|
+
this.closed = true;
|
|
547
|
+
const sock = this.sock;
|
|
548
|
+
if (!sock)
|
|
549
|
+
return;
|
|
550
|
+
sock.end();
|
|
551
|
+
// Await real teardown (like TcpClient.close), but never hang on it.
|
|
552
|
+
await new Promise((resolve) => {
|
|
553
|
+
let done = false;
|
|
554
|
+
const fin = () => { if (!done) {
|
|
555
|
+
done = true;
|
|
556
|
+
resolve();
|
|
557
|
+
} };
|
|
558
|
+
sock.once('close', fin);
|
|
559
|
+
setTimeout(fin, 2000);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
@@ -32,6 +32,9 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
32
32
|
ctx.log('WARN', `auth-sync: ${transport.skipped}`);
|
|
33
33
|
if (transport.error)
|
|
34
34
|
ctx.log('WARN', `auth-sync: shared-store transport: ${transport.error}`);
|
|
35
|
+
if (transport.untrackedBackedUp?.length) {
|
|
36
|
+
ctx.log('WARN', `auth-sync: backed up ${transport.untrackedBackedUp.length} untracked shared-store collision(s) to ${transport.untrackedBackupDir}: ${transport.untrackedBackedUp.join(', ')}`);
|
|
37
|
+
}
|
|
35
38
|
if (!transport.success)
|
|
36
39
|
return;
|
|
37
40
|
const result = await syncReservedAuthBundle();
|
|
@@ -271,7 +271,7 @@ function acquireStartLock() {
|
|
|
271
271
|
*/
|
|
272
272
|
const STOP_LOCK_WAIT_MS = 10_000;
|
|
273
273
|
const STOP_LOCK_POLL_MS = 50;
|
|
274
|
-
function
|
|
274
|
+
function acquireLifecycleLock() {
|
|
275
275
|
const deadline = Date.now() + STOP_LOCK_WAIT_MS;
|
|
276
276
|
for (;;) {
|
|
277
277
|
const release = acquireStartLock();
|
|
@@ -481,14 +481,13 @@ export function isDaemonRunning() {
|
|
|
481
481
|
* liveness check and the write.
|
|
482
482
|
*/
|
|
483
483
|
export function claimDaemonInstance() {
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
// claimer we bailed for becomes the singleton, so last-wins still holds.
|
|
484
|
+
// A stop owns this same lock through teardown. Waiting here is load-bearing:
|
|
485
|
+
// returning false while stopDaemon() holds it lets this replacement exit 0,
|
|
486
|
+
// then the stop completes with no singleton left alive. The bounded lifecycle
|
|
487
|
+
// acquisition also preserves concurrent-start serialization: after the first
|
|
488
|
+
// claimer publishes its pid, the waiter takes the lock and performs the normal
|
|
489
|
+
// last-wins takeover rather than ever running the read-evict-write unlocked.
|
|
490
|
+
const release = acquireLifecycleLock();
|
|
492
491
|
if (!release)
|
|
493
492
|
return false;
|
|
494
493
|
try {
|
|
@@ -2205,7 +2204,7 @@ export function findSurvivingStateDirDaemons(exclude) {
|
|
|
2205
2204
|
* never reports success on an unverified stop.
|
|
2206
2205
|
*/
|
|
2207
2206
|
export function stopDaemon() {
|
|
2208
|
-
const releaseLock =
|
|
2207
|
+
const releaseLock = acquireLifecycleLock();
|
|
2209
2208
|
if (!releaseLock) {
|
|
2210
2209
|
return {
|
|
2211
2210
|
ok: false,
|
|
@@ -48,6 +48,9 @@ export class UsageSyncService extends BasePeriodicService {
|
|
|
48
48
|
ctx.log('WARN', `usage-sync: ${transport.skipped}`);
|
|
49
49
|
if (transport.error)
|
|
50
50
|
ctx.log('WARN', `usage-sync: shared-store transport: ${transport.error}`);
|
|
51
|
+
if (transport.untrackedBackedUp?.length) {
|
|
52
|
+
ctx.log('WARN', `usage-sync: backed up ${transport.untrackedBackedUp.length} untracked shared-store collision(s) to ${transport.untrackedBackupDir}: ${transport.untrackedBackedUp.join(', ')}`);
|
|
53
|
+
}
|
|
51
54
|
if (!transport.success)
|
|
52
55
|
return;
|
|
53
56
|
const consumed = consumeUsageSnapshotsFromSharedStore();
|
|
@@ -18,6 +18,7 @@ import * as yaml from 'yaml';
|
|
|
18
18
|
import { exec } from 'child_process';
|
|
19
19
|
import { getDaemonConfigDir, getRuntimeStateDir } from './state.js';
|
|
20
20
|
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
21
|
+
import { closeServerBounded } from './secrets/agent.js';
|
|
21
22
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
22
23
|
import { startWebhookServer, createFileDeliveryStore, waitForListening } from './triggers/webhook.js';
|
|
23
24
|
import { buildFunnelUpCommand, FUNNEL_PORTS } from './funnel.js';
|
|
@@ -195,8 +196,13 @@ export async function startHostedWebhookReceivers(opts) {
|
|
|
195
196
|
log('WARN', `webhook ${webhook.source}:${webhook.event} dispatch failed after ack: ${err.message}`);
|
|
196
197
|
},
|
|
197
198
|
});
|
|
199
|
+
const sockets = new Set();
|
|
200
|
+
server.on('connection', (socket) => {
|
|
201
|
+
sockets.add(socket);
|
|
202
|
+
socket.once('close', () => sockets.delete(socket));
|
|
203
|
+
});
|
|
198
204
|
await waitForListening(server);
|
|
199
|
-
servers.push(server);
|
|
205
|
+
servers.push({ server, sockets });
|
|
200
206
|
log('INFO', `webhook receiver bound on 127.0.0.1:${port} (bundle ${receiver.bundle})`);
|
|
201
207
|
if (receiver.funnel)
|
|
202
208
|
reconcileFunnel(receiver.funnel.publicPort, port, log);
|
|
@@ -210,6 +216,13 @@ export async function startHostedWebhookReceivers(opts) {
|
|
|
210
216
|
}
|
|
211
217
|
return {
|
|
212
218
|
count: servers.length,
|
|
213
|
-
close: () => Promise.all(servers.map(
|
|
219
|
+
close: () => Promise.all(servers.map(async ({ server, sockets }) => {
|
|
220
|
+
// Stop accepting first, then force every persistent HTTP connection
|
|
221
|
+
// closed so keep-alive cannot hold daemon shutdown open indefinitely.
|
|
222
|
+
const closing = closeServerBounded(server);
|
|
223
|
+
for (const socket of sockets)
|
|
224
|
+
socket.destroy();
|
|
225
|
+
await closing;
|
|
226
|
+
})).then(() => undefined),
|
|
214
227
|
};
|
|
215
228
|
}
|
|
@@ -21,6 +21,18 @@ export interface FleetSharedRepoSyncResult {
|
|
|
21
21
|
timedOut: boolean;
|
|
22
22
|
skipped: string | null;
|
|
23
23
|
error: string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Untracked working-tree files that collided with origin and were dropped
|
|
26
|
+
* (byte-identical to origin) before rebasing. See clearCollidingUntracked.
|
|
27
|
+
*/
|
|
28
|
+
untrackedCleared?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Untracked working-tree files that collided with origin, differed from it,
|
|
31
|
+
* and were moved to untrackedBackupDir before rebasing.
|
|
32
|
+
*/
|
|
33
|
+
untrackedBackedUp?: string[];
|
|
34
|
+
/** Directory the differing untracked collisions were moved to, if any. */
|
|
35
|
+
untrackedBackupDir?: string;
|
|
24
36
|
}
|
|
25
37
|
/** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
|
|
26
38
|
export declare function runBoundedProcess(command: string, args: string[], options: {
|
|
@@ -161,6 +161,72 @@ function retainedAutostash(stashList, objectId) {
|
|
|
161
161
|
ref: null,
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* `git rebase` checks out its base (origin/<branch>) and aborts when an
|
|
166
|
+
* untracked working-tree file would be overwritten by a file that origin
|
|
167
|
+
* tracks. `--autostash` only sets aside *tracked* changes, so these untracked
|
|
168
|
+
* collisions wedge the rebase on every sync and the device silently falls
|
|
169
|
+
* hundreds of commits behind — the fleet-wide drift root cause (PHNX-3923).
|
|
170
|
+
*
|
|
171
|
+
* In this shared-state repo an untracked file that also exists on origin is a
|
|
172
|
+
* stale local snapshot: the canonical copy is on origin and every device
|
|
173
|
+
* republishes its own state (this device's own owned file is already committed
|
|
174
|
+
* before we get here, so it is tracked and never appears below). Drop the ones
|
|
175
|
+
* byte-identical to origin (lossless) and move any that differ into a backup
|
|
176
|
+
* dir beside the repo root — outside the repo's tracked tree, so the move
|
|
177
|
+
* cannot create a fresh collision — so nothing is silently destroyed. Then let
|
|
178
|
+
* the rebase check out origin's version. A file we cannot clear is left in
|
|
179
|
+
* place; the rebase may still abort, no worse than today and never losing data.
|
|
180
|
+
*/
|
|
181
|
+
async function clearCollidingUntracked(git, root, branch) {
|
|
182
|
+
const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
183
|
+
if (others.code !== 0)
|
|
184
|
+
return { error: others };
|
|
185
|
+
const relPaths = others.stdout.split('\0').filter(Boolean);
|
|
186
|
+
let cleared = 0;
|
|
187
|
+
const backedUp = [];
|
|
188
|
+
let backupDir = null;
|
|
189
|
+
for (const rel of relPaths) {
|
|
190
|
+
// rev-parse doubles as the "does origin track this path" test (non-zero =
|
|
191
|
+
// absent) — only paths origin tracks are overwritten by the rebase checkout.
|
|
192
|
+
const originHash = await git(['rev-parse', `origin/${branch}:${rel}`]);
|
|
193
|
+
if (originHash.code !== 0 || !originHash.stdout.trim())
|
|
194
|
+
continue;
|
|
195
|
+
const abs = path.join(root, rel);
|
|
196
|
+
// Byte-exact identity via git blob SHAs. Comparing UTF-8-decoded strings
|
|
197
|
+
// would collapse distinct invalid bytes to U+FFFD and could delete
|
|
198
|
+
// non-identical content. `--no-filters` hashes the raw on-disk bytes (no
|
|
199
|
+
// clean/autocrlf normalization), so `identical` is true only when the local
|
|
200
|
+
// bytes exactly equal origin's stored blob — never a filter-normalized
|
|
201
|
+
// near-match. A hash-object failure (unreadable / vanished under a
|
|
202
|
+
// concurrent writer) means "leave it in place".
|
|
203
|
+
const localHash = await git(['hash-object', '--no-filters', '--', abs]);
|
|
204
|
+
if (localHash.code !== 0 || !localHash.stdout.trim())
|
|
205
|
+
continue;
|
|
206
|
+
const identical = originHash.stdout.trim() === localHash.stdout.trim();
|
|
207
|
+
try {
|
|
208
|
+
if (identical) {
|
|
209
|
+
fs.rmSync(abs, { force: true });
|
|
210
|
+
cleared++;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
if (!backupDir) {
|
|
214
|
+
// A sibling of the repo root: outside the tracked tree (so the move
|
|
215
|
+
// cannot create a fresh collision) yet colocated for easy recovery.
|
|
216
|
+
backupDir = path.join(`${root}-fleet-sync-backups`, String(Date.now()));
|
|
217
|
+
}
|
|
218
|
+
const dest = path.join(backupDir, rel);
|
|
219
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
220
|
+
fs.renameSync(abs, dest);
|
|
221
|
+
backedUp.push(rel);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Could not clear this collision; leave it in place.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { cleared, backedUp, backupDir };
|
|
229
|
+
}
|
|
164
230
|
async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
165
231
|
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
166
232
|
return {
|
|
@@ -201,29 +267,57 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
|
201
267
|
}
|
|
202
268
|
const ownedFile = fleetSharedStatePath(device, root);
|
|
203
269
|
const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
|
|
270
|
+
// The central `agents.yaml` is fleet-shared state too — `agents accounts label`
|
|
271
|
+
// writes version-scoped native account rows there (lib/state.ts writeMetaUnlocked),
|
|
272
|
+
// as a plain file write with no commit. Publishing only the per-device file and
|
|
273
|
+
// then `rebase --autostash`-ing over a dirty central file silently destroyed
|
|
274
|
+
// those labels: every box lost its account labels on its next daemon publish
|
|
275
|
+
// (PHNX-3887). Commit it alongside the device doc so the rebase carries the
|
|
276
|
+
// rows instead of stashing them, which is also what `accounts label --help`
|
|
277
|
+
// already promises ("labels live on the central account rows in agents.yaml,
|
|
278
|
+
// which repo push/pull already syncs fleet-wide").
|
|
279
|
+
const centralFile = path.join(root, 'agents.yaml');
|
|
280
|
+
const publishPaths = [relativeOwnedFile];
|
|
281
|
+
if (fs.existsSync(centralFile))
|
|
282
|
+
publishPaths.push('agents.yaml');
|
|
283
|
+
const existingPaths = publishPaths.filter(rel => fs.existsSync(path.join(root, rel)));
|
|
204
284
|
let committed = false;
|
|
205
|
-
if (
|
|
206
|
-
const status = await git(['status', '--porcelain=v1', '--',
|
|
285
|
+
if (existingPaths.length > 0) {
|
|
286
|
+
const status = await git(['status', '--porcelain=v1', '--', ...existingPaths]);
|
|
207
287
|
if (status.code !== 0)
|
|
208
288
|
return failure('git status', status);
|
|
209
289
|
if (status.stdout.trim()) {
|
|
210
|
-
const add = await git(['add', '--',
|
|
290
|
+
const add = await git(['add', '--', ...existingPaths]);
|
|
211
291
|
if (add.code !== 0)
|
|
212
292
|
return failure('git add', add);
|
|
213
293
|
const commit = await git([
|
|
214
294
|
'-c', 'commit.gpgsign=false',
|
|
215
295
|
'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
|
|
216
|
-
'--',
|
|
296
|
+
'--', ...existingPaths,
|
|
217
297
|
]);
|
|
218
298
|
if (commit.code !== 0)
|
|
219
299
|
return failure('git commit', commit);
|
|
220
300
|
committed = true;
|
|
221
301
|
}
|
|
222
302
|
}
|
|
303
|
+
let untrackedCleared = 0;
|
|
304
|
+
const untrackedBackedUp = [];
|
|
305
|
+
let untrackedBackupDir;
|
|
223
306
|
for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
|
|
224
307
|
const fetch = await git(['fetch', 'origin']);
|
|
225
308
|
if (fetch.code !== 0)
|
|
226
309
|
return { ...failure('git fetch', fetch), committed };
|
|
310
|
+
// Untracked files that origin tracks would abort the rebase's checkout
|
|
311
|
+
// (autostash only covers tracked changes). Clear them first — this is the
|
|
312
|
+
// fleet-drift root cause (PHNX-3923).
|
|
313
|
+
const reconcile = await clearCollidingUntracked(git, root, branch);
|
|
314
|
+
if ('error' in reconcile) {
|
|
315
|
+
return { ...failure('git ls-files --others', reconcile.error), committed };
|
|
316
|
+
}
|
|
317
|
+
untrackedCleared += reconcile.cleared;
|
|
318
|
+
untrackedBackedUp.push(...reconcile.backedUp);
|
|
319
|
+
if (reconcile.backupDir)
|
|
320
|
+
untrackedBackupDir = reconcile.backupDir;
|
|
227
321
|
// Keep enough of the same wall-clock bound available to remove a botched
|
|
228
322
|
// autostash pop from the operator's live checkout before returning.
|
|
229
323
|
const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
|
|
@@ -283,6 +377,9 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
|
|
|
283
377
|
timedOut: false,
|
|
284
378
|
skipped: null,
|
|
285
379
|
error: null,
|
|
380
|
+
untrackedCleared,
|
|
381
|
+
untrackedBackedUp,
|
|
382
|
+
untrackedBackupDir,
|
|
286
383
|
};
|
|
287
384
|
}
|
|
288
385
|
if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
|
|
@@ -124,6 +124,8 @@ export declare function resolvePeerTarget(machine: string): Promise<{
|
|
|
124
124
|
* error, timeout, version-skewed peer with no `--json` preview envelope.
|
|
125
125
|
*/
|
|
126
126
|
export declare function fetchPeerPreviewDigest(sessionId: string, machine: string, timeoutMs?: number): Promise<unknown | undefined>;
|
|
127
|
+
/** Parse the JSON envelope returned by `sessions preview --json`. */
|
|
128
|
+
export declare function parsePeerPreviewDigest(parsed: unknown): unknown | undefined;
|
|
127
129
|
/**
|
|
128
130
|
* Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
|
|
129
131
|
* stdio). `args` is the full arg vector after the binary — callers pass e.g.
|
|
@@ -554,6 +554,10 @@ export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEE
|
|
|
554
554
|
catch {
|
|
555
555
|
return undefined;
|
|
556
556
|
}
|
|
557
|
+
return parsePeerPreviewDigest(parsed);
|
|
558
|
+
}
|
|
559
|
+
/** Parse the JSON envelope returned by `sessions preview --json`. */
|
|
560
|
+
export function parsePeerPreviewDigest(parsed) {
|
|
557
561
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
558
562
|
return undefined;
|
|
559
563
|
const preview = parsed.preview;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.75",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|