@phnx-labs/agents-cli 1.20.28 → 1.20.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/exec.js +22 -10
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/secrets.js +93 -6
- package/dist/commands/sessions.js +157 -44
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.js +70 -14
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +54 -5
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/active.js +79 -18
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +12 -0
- package/dist/lib/session/db.js +66 -9
- package/dist/lib/session/discover.d.ts +7 -0
- package/dist/lib/session/discover.js +309 -0
- package/dist/lib/session/parse.d.ts +22 -0
- package/dist/lib/session/parse.js +132 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +10 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- package/package.json +1 -1
package/dist/lib/shims.js
CHANGED
|
@@ -16,7 +16,7 @@ import { confirm, select } from '@inquirer/prompts';
|
|
|
16
16
|
import { IS_WINDOWS, prependToWindowsUserPath } from './platform/index.js';
|
|
17
17
|
import { getShimsDir, getVersionsDir, getBackupsDir, ensureAgentsDir } from './state.js';
|
|
18
18
|
export { getShimsDir };
|
|
19
|
-
import { AGENTS } from './agents.js';
|
|
19
|
+
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
20
20
|
/**
|
|
21
21
|
* Files and directories to always skip during conflict detection and migration.
|
|
22
22
|
* These are never user config that should be migrated.
|
|
@@ -596,8 +596,14 @@ export function removeShim(agent) {
|
|
|
596
596
|
* v5 — hard-disable Codex startup update checks in versioned aliases.
|
|
597
597
|
* v6 — versions moved from ~/.agents-system/versions to ~/.agents/versions
|
|
598
598
|
* (two-repo split: system = shipped defaults, user = operational state).
|
|
599
|
+
* v7 — runtime state split into ~/.agents/.history and ~/.agents/.cache.
|
|
600
|
+
* v8 — resolve grok/kimi/droid binaries from their real install locations
|
|
601
|
+
* (~/.grok/downloads, ~/.kimi-code/bin, ~/.local/bin) instead of the
|
|
602
|
+
* hardcoded node_modules/.bin, which never exists for these three and
|
|
603
|
+
* made every versioned alias (the path `agents teams` pins to) fail
|
|
604
|
+
* with "<agent>@<version> not installed". Also emit GROK_HOME.
|
|
599
605
|
*/
|
|
600
|
-
export const VERSIONED_ALIAS_SCHEMA_VERSION =
|
|
606
|
+
export const VERSIONED_ALIAS_SCHEMA_VERSION = 8;
|
|
601
607
|
/** Internal marker string used to embed the schema version in versioned alias scripts. */
|
|
602
608
|
const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
|
|
603
609
|
// The version string is interpolated into a generated bash script and into
|
|
@@ -640,22 +646,70 @@ export CODEX_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${co
|
|
|
640
646
|
# version MCP and session state are isolated.
|
|
641
647
|
export COPILOT_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
|
|
642
648
|
`
|
|
643
|
-
: agent === '
|
|
649
|
+
: agent === 'grok'
|
|
644
650
|
? `
|
|
651
|
+
# Grok Build uses GROK_HOME to isolate its entire configuration tree (skills,
|
|
652
|
+
# hooks, plugins, agents, memory, sessions, config.toml, MCP). Point direct
|
|
653
|
+
# aliases at the versioned home for isolation parity with the main shim.
|
|
654
|
+
export GROK_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
|
|
655
|
+
`
|
|
656
|
+
: agent === 'kimi'
|
|
657
|
+
? `
|
|
645
658
|
# Kimi Code CLI honors KIMI_CODE_HOME to relocate ~/.kimi-code (config.toml,
|
|
646
659
|
# mcp.json, sessions, skills, hooks). Point direct aliases at the versioned home.
|
|
647
660
|
export KIMI_CODE_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/${configDirName}"
|
|
648
661
|
`
|
|
649
|
-
|
|
662
|
+
: '';
|
|
650
663
|
const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
|
|
664
|
+
// Resolve the binary the same way the main shim does (see generateShimScript).
|
|
665
|
+
// Grok, Kimi, and Droid do NOT ship into node_modules/.bin — Grok downloads a
|
|
666
|
+
// native binary to ~/.grok/downloads, Kimi to ~/.kimi-code/bin, and Droid
|
|
667
|
+
// (Factory AI) installs a standalone binary to ~/.local/bin. Hardcoding the
|
|
668
|
+
// node_modules path made every versioned alias for these three fail with
|
|
669
|
+
// "<agent>@<version> not installed", which is exactly the path `agents teams`
|
|
670
|
+
// takes once it pins a teammate's version.
|
|
671
|
+
// This template is unix-only — on Windows the .cmd companion delegates to
|
|
672
|
+
// "agents __shim" which resolves via getBinaryPath() instead.
|
|
673
|
+
const versionDir = `$HOME/.agents/.history/versions/${agent}/${version}`;
|
|
674
|
+
const binaryResolution = agent === 'grok'
|
|
675
|
+
? `# Grok ships its native binary in ~/.grok/downloads, not node_modules.
|
|
676
|
+
GROK_DOWNLOADS="$HOME/.grok/downloads"
|
|
677
|
+
BINARY=""
|
|
678
|
+
if [ -d "$GROK_DOWNLOADS" ]; then
|
|
679
|
+
BINARY=$(ls "$GROK_DOWNLOADS"/grok-* 2>/dev/null | grep -i "${version}" | head -1)
|
|
680
|
+
[ -n "$BINARY" ] || BINARY=$(ls "$GROK_DOWNLOADS"/grok-* 2>/dev/null | head -1)
|
|
681
|
+
fi
|
|
682
|
+
[ -n "$BINARY" ] && [ -x "$BINARY" ] || BINARY=$(command -v grok 2>/dev/null || echo "")`
|
|
683
|
+
: agent === 'kimi'
|
|
684
|
+
? `# Kimi ships its binary in ~/.kimi-code/bin, not node_modules.
|
|
685
|
+
KIMI_BINARY="$HOME/.kimi-code/bin/kimi"
|
|
686
|
+
if [ -x "$KIMI_BINARY" ]; then
|
|
687
|
+
BINARY="$KIMI_BINARY"
|
|
688
|
+
else
|
|
689
|
+
BINARY=$(command -v kimi 2>/dev/null || echo "")
|
|
690
|
+
fi`
|
|
691
|
+
: agent === 'droid'
|
|
692
|
+
? `# Droid (Factory AI) installs a standalone native binary at ~/.local/bin/droid;
|
|
693
|
+
# there is no npm package and nothing lands in node_modules/.bin. The PATH
|
|
694
|
+
# fallback refuses anything under our shims dir to avoid an infinite re-exec.
|
|
695
|
+
DROID_BINARY="$HOME/.local/bin/droid"
|
|
696
|
+
if [ -x "$DROID_BINARY" ]; then
|
|
697
|
+
BINARY="$DROID_BINARY"
|
|
698
|
+
else
|
|
699
|
+
BINARY=$(command -v droid 2>/dev/null || echo "")
|
|
700
|
+
case "$BINARY" in
|
|
701
|
+
"$HOME/.agents/.cache/shims/"*) BINARY="" ;;
|
|
702
|
+
esac
|
|
703
|
+
fi`
|
|
704
|
+
: `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
|
|
651
705
|
return `#!/bin/bash
|
|
652
706
|
# Auto-generated by agents-cli - do not edit
|
|
653
707
|
# ${VERSIONED_ALIAS_VERSION_MARKER} ${VERSIONED_ALIAS_SCHEMA_VERSION}
|
|
654
708
|
# Direct alias for ${agentConfig.name}@${version}
|
|
655
709
|
|
|
656
|
-
|
|
710
|
+
${binaryResolution}
|
|
657
711
|
|
|
658
|
-
if [ ! -x "$BINARY" ]; then
|
|
712
|
+
if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
|
|
659
713
|
echo "agents: ${agent}@${version} not installed" >&2
|
|
660
714
|
exit 1
|
|
661
715
|
fi
|
|
@@ -831,6 +885,70 @@ function detectMigrationConflicts(agent, version) {
|
|
|
831
885
|
*
|
|
832
886
|
* Returns: { success: boolean, backupPath?: string, error?: string }
|
|
833
887
|
*/
|
|
888
|
+
/**
|
|
889
|
+
* Seed a version's config home with the account credential so switching versions
|
|
890
|
+
* doesn't log the CLI out. Droid/antigravity/kimi (registry `authFiles`) store
|
|
891
|
+
* login as files inside the per-version config dir; sign-in is account-global,
|
|
892
|
+
* so we copy the FRESHEST existing copy (by mtime, across all installed version
|
|
893
|
+
* homes) into `toConfigDir` when its copy is missing or older. mtime is
|
|
894
|
+
* preserved so the "freshest" comparison stays stable and switches don't
|
|
895
|
+
* ping-pong. Best-effort: a failed copy just means the user re-logs in.
|
|
896
|
+
*/
|
|
897
|
+
export function carryForwardAuthFiles(agent, toConfigDir) {
|
|
898
|
+
const authFiles = AGENTS[agent].authFiles;
|
|
899
|
+
if (!authFiles || authFiles.length === 0)
|
|
900
|
+
return;
|
|
901
|
+
const configDirName = agentConfigDirName(agent);
|
|
902
|
+
const versionsBase = path.join(getVersionsDir(), agent);
|
|
903
|
+
let sourceDirs = [];
|
|
904
|
+
try {
|
|
905
|
+
sourceDirs = fs
|
|
906
|
+
.readdirSync(versionsBase)
|
|
907
|
+
.map(v => path.join(versionsBase, v, 'home', configDirName));
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
return; // no installed versions to source from
|
|
911
|
+
}
|
|
912
|
+
for (const rel of authFiles) {
|
|
913
|
+
const dest = path.join(toConfigDir, rel);
|
|
914
|
+
const destResolved = path.resolve(dest);
|
|
915
|
+
// Newest existing source copy across all version homes (excluding dest).
|
|
916
|
+
let newest = null;
|
|
917
|
+
for (const dir of sourceDirs) {
|
|
918
|
+
const src = path.join(dir, rel);
|
|
919
|
+
if (path.resolve(src) === destResolved)
|
|
920
|
+
continue;
|
|
921
|
+
let st;
|
|
922
|
+
try {
|
|
923
|
+
st = fs.statSync(src);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (!st.isFile())
|
|
929
|
+
continue;
|
|
930
|
+
if (!newest || st.mtimeMs > newest.mtimeMs)
|
|
931
|
+
newest = { path: src, mtimeMs: st.mtimeMs };
|
|
932
|
+
}
|
|
933
|
+
if (!newest)
|
|
934
|
+
continue;
|
|
935
|
+
// Skip when the target already has an at-least-as-fresh copy.
|
|
936
|
+
try {
|
|
937
|
+
const dstat = fs.statSync(dest);
|
|
938
|
+
if (dstat.mtimeMs >= newest.mtimeMs)
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
catch { /* dest missing — copy below */ }
|
|
942
|
+
try {
|
|
943
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
944
|
+
const srcStat = fs.statSync(newest.path);
|
|
945
|
+
fs.copyFileSync(newest.path, dest);
|
|
946
|
+
fs.chmodSync(dest, (srcStat.mode & 0o777) || 0o600);
|
|
947
|
+
fs.utimesSync(dest, srcStat.atime, srcStat.mtime);
|
|
948
|
+
}
|
|
949
|
+
catch { /* best-effort; a failed carry just means a re-login */ }
|
|
950
|
+
}
|
|
951
|
+
}
|
|
834
952
|
export async function switchConfigSymlink(agent, version) {
|
|
835
953
|
const configPath = getAgentConfigPath(agent);
|
|
836
954
|
const versionConfigPath = getVersionConfigPath(agent, version);
|
|
@@ -838,6 +956,12 @@ export async function switchConfigSymlink(agent, version) {
|
|
|
838
956
|
if (!fs.existsSync(versionConfigPath)) {
|
|
839
957
|
fs.mkdirSync(versionConfigPath, { recursive: true });
|
|
840
958
|
}
|
|
959
|
+
// Carry the account credential into the version we're switching to. Droid /
|
|
960
|
+
// antigravity / kimi store login as files INSIDE the per-version config home;
|
|
961
|
+
// switching versions repoints the symlink to a home that was never logged in,
|
|
962
|
+
// silently logging the CLI out. Sign-in is account-global, so seed the target
|
|
963
|
+
// home with the freshest existing credential before we flip the symlink.
|
|
964
|
+
carryForwardAuthFiles(agent, versionConfigPath);
|
|
841
965
|
try {
|
|
842
966
|
const stat = fs.lstatSync(configPath);
|
|
843
967
|
if (stat.isSymbolicLink()) {
|
|
@@ -0,0 +1,127 @@
|
|
|
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 --host` 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 { type ChildProcess } from 'child_process';
|
|
20
|
+
import { type DeviceProfile } from './devices/registry.js';
|
|
21
|
+
export interface StartTunnelOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Detach the tunnel so it OUTLIVES this CLI process. Used by
|
|
24
|
+
* `agents computer start --host` — the tunnel must persist across separate
|
|
25
|
+
* verb invocations (`apps`, `click`, …) until `stop --host` tears it down.
|
|
26
|
+
* The browser driver leaves this false: it holds the tunnel for the lifetime
|
|
27
|
+
* of one CDP session and kills it on cleanup.
|
|
28
|
+
*/
|
|
29
|
+
detached?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure. */
|
|
32
|
+
export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number): string[];
|
|
33
|
+
/**
|
|
34
|
+
* Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
|
|
35
|
+
*
|
|
36
|
+
* Foreground (default): stderr is captured so a tunnel that dies inside 500ms
|
|
37
|
+
* rejects with the ssh error — the browser driver's original contract. Detached
|
|
38
|
+
* mode ignores stdio and `unref`s the child so the parent can exit while the
|
|
39
|
+
* tunnel lives; liveness is then confirmed by the caller probing the service.
|
|
40
|
+
*/
|
|
41
|
+
export declare function startSSHTunnel(user: string, host: string, localPort: number, remotePort: number, opts?: StartTunnelOptions): Promise<ChildProcess>;
|
|
42
|
+
/** Loopback TCP port the Windows daemon binds on the remote (Program.cs default). */
|
|
43
|
+
export declare const REMOTE_HELPER_PORT = 8765;
|
|
44
|
+
/** Task Scheduler task name for the daemon. Stable so setup/stop pair up. */
|
|
45
|
+
export declare const REMOTE_TASK_NAME = "AgentsComputerHelper";
|
|
46
|
+
/** Basename of the cross-published exe under packages/computer-helper-win/dist. */
|
|
47
|
+
export declare const WIN_HELPER_EXE = "computer-helper-win.exe";
|
|
48
|
+
/**
|
|
49
|
+
* Locate the cross-published Windows daemon exe. Only the local build output is
|
|
50
|
+
* a candidate — `scripts/build-win.sh` writes it to packages/.../dist/.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveWinHelperExe(): string | null;
|
|
53
|
+
/** Persisted per-device tunnel state so verbs can reconnect after `start --host`. */
|
|
54
|
+
export interface RemoteTunnelState {
|
|
55
|
+
device: string;
|
|
56
|
+
target: string;
|
|
57
|
+
localPort: number;
|
|
58
|
+
remotePort: number;
|
|
59
|
+
tunnelPid: number;
|
|
60
|
+
token: string | null;
|
|
61
|
+
taskName: string;
|
|
62
|
+
startedAt: number;
|
|
63
|
+
}
|
|
64
|
+
/** State file path for a device. Device names are ssh-alias safe (validated). */
|
|
65
|
+
export declare function remoteStatePath(device: string): string;
|
|
66
|
+
export declare function readRemoteState(device: string): RemoteTunnelState | null;
|
|
67
|
+
export declare function writeRemoteState(state: RemoteTunnelState): void;
|
|
68
|
+
export declare function clearRemoteState(device: string): void;
|
|
69
|
+
/** Resolve a registered device to its ssh pieces, or throw a clear error. */
|
|
70
|
+
export declare function resolveRemoteDevice(name: string): Promise<{
|
|
71
|
+
device: DeviceProfile;
|
|
72
|
+
target: string;
|
|
73
|
+
user: string;
|
|
74
|
+
host: string;
|
|
75
|
+
}>;
|
|
76
|
+
/**
|
|
77
|
+
* PowerShell that streams base64 from stdin, decodes it incrementally to
|
|
78
|
+
* %LOCALAPPDATA%\agents\computer-helper-win.exe, and stops any running instance
|
|
79
|
+
* first so the file isn't locked. The CryptoStream/FromBase64Transform decode
|
|
80
|
+
* is streaming — the ~156MB exe never lands in memory whole on the remote.
|
|
81
|
+
*/
|
|
82
|
+
export declare function buildPushScript(): string;
|
|
83
|
+
/**
|
|
84
|
+
* PowerShell that registers the daemon as a LOGON scheduled task. Interactive
|
|
85
|
+
* logon type + Highest run level so the daemon runs in the real desktop session
|
|
86
|
+
* (UIAutomation and ScreenCapture need a live session, not Session 0) and
|
|
87
|
+
* survives ssh disconnect — the same rationale as the browser WMI launch. The
|
|
88
|
+
* task is started immediately so the caller need not log out/in.
|
|
89
|
+
*/
|
|
90
|
+
export declare function buildRegisterTaskScript(port: number, taskName: string): string;
|
|
91
|
+
/** PowerShell that unregisters the task and stops any running daemon process. */
|
|
92
|
+
export declare function buildUnregisterTaskScript(taskName: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* `setup --host`: push the exe, then register + start the LOGON task. Both hops
|
|
95
|
+
* go through `sshExec` (BatchMode key auth — the same hardening the browser
|
|
96
|
+
* driver and `agents ssh` use). Throws with the remote stderr on any failure.
|
|
97
|
+
*/
|
|
98
|
+
export declare function setupRemoteHelper(name: string): Promise<{
|
|
99
|
+
target: string;
|
|
100
|
+
taskName: string;
|
|
101
|
+
}>;
|
|
102
|
+
/** Reserve a free local TCP port by binding :0 and reading the assigned port. */
|
|
103
|
+
export declare function pickFreePort(): Promise<number>;
|
|
104
|
+
/**
|
|
105
|
+
* `start --host`: open a detached ssh -L tunnel to the remote daemon, verify it
|
|
106
|
+
* answers over TCP, and persist the tunnel state so verbs can reconnect. Returns
|
|
107
|
+
* the state (and leaves the tunnel running in the background).
|
|
108
|
+
*/
|
|
109
|
+
export declare function startRemoteTunnel(name: string): Promise<RemoteTunnelState>;
|
|
110
|
+
/**
|
|
111
|
+
* `stop --host`: kill the local tunnel, unregister the remote task (best-effort
|
|
112
|
+
* — the box may be offline), and clear the persisted state.
|
|
113
|
+
*/
|
|
114
|
+
export declare function stopRemoteHelper(name: string): Promise<{
|
|
115
|
+
tunnelKilled: boolean;
|
|
116
|
+
taskRemoved: boolean;
|
|
117
|
+
}>;
|
|
118
|
+
/**
|
|
119
|
+
* Point this process's RPC client at a device's live tunnel by setting
|
|
120
|
+
* COMPUTER_HELPER_TCP / COMPUTER_HELPER_TOKEN from persisted state. Called for
|
|
121
|
+
* remote verbs (`apps --host`, `click --host`, …) so the shared
|
|
122
|
+
* openComputerClient() transparently selects the TcpClient transport — no
|
|
123
|
+
* per-verb wiring. Exits with guidance when there is no active tunnel.
|
|
124
|
+
*/
|
|
125
|
+
export declare function hydrateRemoteEnvFromState(name: string): void;
|
|
126
|
+
/** Generate a shared-secret token (reserved for token-file provisioning). */
|
|
127
|
+
export declare function generateToken(): string;
|
|
@@ -0,0 +1,346 @@
|
|
|
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 --host` 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 { sshExec } from './ssh-exec.js';
|
|
26
|
+
import { encodePowerShell } from './browser/drivers/ssh.js';
|
|
27
|
+
import { getDevice } from './devices/registry.js';
|
|
28
|
+
import { sshTargetFor } from './devices/connect.js';
|
|
29
|
+
import { hostNameFor } from './devices/ssh-config.js';
|
|
30
|
+
import { getCacheDir } from './state.js';
|
|
31
|
+
import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
|
|
32
|
+
/** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure. */
|
|
33
|
+
export function buildTunnelArgs(user, host, localPort, remotePort) {
|
|
34
|
+
return [
|
|
35
|
+
'-L',
|
|
36
|
+
`${localPort}:127.0.0.1:${remotePort}`,
|
|
37
|
+
`${user}@${host}`,
|
|
38
|
+
'-N',
|
|
39
|
+
'-o',
|
|
40
|
+
'StrictHostKeyChecking=accept-new',
|
|
41
|
+
'-o',
|
|
42
|
+
'BatchMode=yes',
|
|
43
|
+
'-o',
|
|
44
|
+
'ConnectTimeout=10',
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
|
|
49
|
+
*
|
|
50
|
+
* Foreground (default): stderr is captured so a tunnel that dies inside 500ms
|
|
51
|
+
* rejects with the ssh error — the browser driver's original contract. Detached
|
|
52
|
+
* mode ignores stdio and `unref`s the child so the parent can exit while the
|
|
53
|
+
* tunnel lives; liveness is then confirmed by the caller probing the service.
|
|
54
|
+
*/
|
|
55
|
+
export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const args = buildTunnelArgs(user, host, localPort, remotePort);
|
|
58
|
+
const tunnel = spawn('ssh', args, {
|
|
59
|
+
stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
|
|
60
|
+
detached: Boolean(opts.detached),
|
|
61
|
+
});
|
|
62
|
+
let stderr = '';
|
|
63
|
+
tunnel.stderr?.on('data', (data) => {
|
|
64
|
+
stderr += data.toString();
|
|
65
|
+
});
|
|
66
|
+
tunnel.on('error', (err) => {
|
|
67
|
+
reject(new Error(`SSH tunnel failed: ${err.message}`));
|
|
68
|
+
});
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
if (tunnel.killed) {
|
|
71
|
+
reject(new Error(`SSH tunnel died: ${stderr}`));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Let the CLI exit without waiting on a persistent tunnel.
|
|
75
|
+
if (opts.detached)
|
|
76
|
+
tunnel.unref();
|
|
77
|
+
resolve(tunnel);
|
|
78
|
+
}
|
|
79
|
+
}, 500);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// 2. Remote computer-helper orchestration
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/** Loopback TCP port the Windows daemon binds on the remote (Program.cs default). */
|
|
86
|
+
export const REMOTE_HELPER_PORT = 8765;
|
|
87
|
+
/** Task Scheduler task name for the daemon. Stable so setup/stop pair up. */
|
|
88
|
+
export const REMOTE_TASK_NAME = 'AgentsComputerHelper';
|
|
89
|
+
/** Basename of the cross-published exe under packages/computer-helper-win/dist. */
|
|
90
|
+
export const WIN_HELPER_EXE = 'computer-helper-win.exe';
|
|
91
|
+
/**
|
|
92
|
+
* Locate the cross-published Windows daemon exe. Only the local build output is
|
|
93
|
+
* a candidate — `scripts/build-win.sh` writes it to packages/.../dist/.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveWinHelperExe() {
|
|
96
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
97
|
+
const candidates = [
|
|
98
|
+
// Running from the agents-cli checkout (src/lib -> repo root).
|
|
99
|
+
path.resolve(here, '..', '..', 'packages', 'computer-helper-win', 'dist', WIN_HELPER_EXE),
|
|
100
|
+
// Bundled with the npm package (dist/lib -> package root).
|
|
101
|
+
path.resolve(here, '..', 'computer-helper-win', WIN_HELPER_EXE),
|
|
102
|
+
];
|
|
103
|
+
for (const c of candidates) {
|
|
104
|
+
if (fs.existsSync(c))
|
|
105
|
+
return c;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function remoteStateDir() {
|
|
110
|
+
return path.join(getCacheDir(), 'computer', 'remote');
|
|
111
|
+
}
|
|
112
|
+
/** State file path for a device. Device names are ssh-alias safe (validated). */
|
|
113
|
+
export function remoteStatePath(device) {
|
|
114
|
+
return path.join(remoteStateDir(), `${device}.json`);
|
|
115
|
+
}
|
|
116
|
+
export function readRemoteState(device) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(fs.readFileSync(remoteStatePath(device), 'utf-8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function writeRemoteState(state) {
|
|
125
|
+
const dir = remoteStateDir();
|
|
126
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
127
|
+
fs.writeFileSync(remoteStatePath(state.device), JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
128
|
+
}
|
|
129
|
+
export function clearRemoteState(device) {
|
|
130
|
+
try {
|
|
131
|
+
fs.unlinkSync(remoteStatePath(device));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Resolve a registered device to its ssh pieces, or throw a clear error. */
|
|
138
|
+
export async function resolveRemoteDevice(name) {
|
|
139
|
+
const device = await getDevice(name);
|
|
140
|
+
if (!device) {
|
|
141
|
+
throw new Error(`Unknown device '${name}'. Register it with \`agents devices add\` / \`agents devices sync\`, then retry.`);
|
|
142
|
+
}
|
|
143
|
+
if (device.platform !== 'windows') {
|
|
144
|
+
throw new Error(`Device '${name}' is ${device.platform}, not windows. \`agents computer --host\` drives the Windows computer-helper daemon.`);
|
|
145
|
+
}
|
|
146
|
+
const target = sshTargetFor(device); // validates address + injection guard
|
|
147
|
+
const host = hostNameFor(device); // sshTargetFor already threw if absent
|
|
148
|
+
const user = device.user || process.env.USER || 'Administrator';
|
|
149
|
+
return { device, target, user, host };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* PowerShell that streams base64 from stdin, decodes it incrementally to
|
|
153
|
+
* %LOCALAPPDATA%\agents\computer-helper-win.exe, and stops any running instance
|
|
154
|
+
* first so the file isn't locked. The CryptoStream/FromBase64Transform decode
|
|
155
|
+
* is streaming — the ~156MB exe never lands in memory whole on the remote.
|
|
156
|
+
*/
|
|
157
|
+
export function buildPushScript() {
|
|
158
|
+
return [
|
|
159
|
+
`$dir = Join-Path $env:LOCALAPPDATA 'agents'`,
|
|
160
|
+
`New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
|
|
161
|
+
`$dst = Join-Path $dir '${WIN_HELPER_EXE}'`,
|
|
162
|
+
`Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
|
|
163
|
+
`$si = [Console]::OpenStandardInput()`,
|
|
164
|
+
`$t = New-Object Security.Cryptography.FromBase64Transform`,
|
|
165
|
+
`$cs = New-Object Security.Cryptography.CryptoStream($si, $t, [Security.Cryptography.CryptoStreamMode]::Read)`,
|
|
166
|
+
`$fs = [IO.File]::Create($dst)`,
|
|
167
|
+
`$cs.CopyTo($fs)`,
|
|
168
|
+
`$fs.Close(); $cs.Close()`,
|
|
169
|
+
`Write-Output $dst`,
|
|
170
|
+
].join('; ');
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* PowerShell that registers the daemon as a LOGON scheduled task. Interactive
|
|
174
|
+
* logon type + Highest run level so the daemon runs in the real desktop session
|
|
175
|
+
* (UIAutomation and ScreenCapture need a live session, not Session 0) and
|
|
176
|
+
* survives ssh disconnect — the same rationale as the browser WMI launch. The
|
|
177
|
+
* task is started immediately so the caller need not log out/in.
|
|
178
|
+
*/
|
|
179
|
+
export function buildRegisterTaskScript(port, taskName) {
|
|
180
|
+
return [
|
|
181
|
+
`$exe = Join-Path (Join-Path $env:LOCALAPPDATA 'agents') '${WIN_HELPER_EXE}'`,
|
|
182
|
+
`$action = New-ScheduledTaskAction -Execute $exe -Argument '--port ${port}'`,
|
|
183
|
+
`$trigger = New-ScheduledTaskTrigger -AtLogOn`,
|
|
184
|
+
`$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest`,
|
|
185
|
+
`$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)`,
|
|
186
|
+
`Register-ScheduledTask -TaskName '${taskName}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null`,
|
|
187
|
+
`Start-ScheduledTask -TaskName '${taskName}'`,
|
|
188
|
+
].join('; ');
|
|
189
|
+
}
|
|
190
|
+
/** PowerShell that unregisters the task and stops any running daemon process. */
|
|
191
|
+
export function buildUnregisterTaskScript(taskName) {
|
|
192
|
+
return [
|
|
193
|
+
`Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction SilentlyContinue`,
|
|
194
|
+
`Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
|
|
195
|
+
].join('; ');
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* `setup --host`: push the exe, then register + start the LOGON task. Both hops
|
|
199
|
+
* go through `sshExec` (BatchMode key auth — the same hardening the browser
|
|
200
|
+
* driver and `agents ssh` use). Throws with the remote stderr on any failure.
|
|
201
|
+
*/
|
|
202
|
+
export async function setupRemoteHelper(name) {
|
|
203
|
+
const { target } = await resolveRemoteDevice(name);
|
|
204
|
+
const exe = resolveWinHelperExe();
|
|
205
|
+
if (!exe) {
|
|
206
|
+
throw new Error(`Windows helper exe not built. Run: bash scripts/build-win.sh`);
|
|
207
|
+
}
|
|
208
|
+
// Push: base64 the exe locally, stream it over ssh stdin to the decoder.
|
|
209
|
+
const b64 = fs.readFileSync(exe).toString('base64');
|
|
210
|
+
const push = sshExec(target, encodePowerShell(buildPushScript()), {
|
|
211
|
+
input: b64,
|
|
212
|
+
timeoutMs: 600_000, // ~156MB over the wire — allow up to 10 minutes
|
|
213
|
+
});
|
|
214
|
+
if (push.code !== 0) {
|
|
215
|
+
throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim() || push.stdout.trim()}`);
|
|
216
|
+
}
|
|
217
|
+
// Register + start the LOGON task.
|
|
218
|
+
const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
|
|
219
|
+
timeoutMs: 60_000,
|
|
220
|
+
});
|
|
221
|
+
if (reg.code !== 0) {
|
|
222
|
+
throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
|
|
223
|
+
}
|
|
224
|
+
return { target, taskName: REMOTE_TASK_NAME };
|
|
225
|
+
}
|
|
226
|
+
/** Reserve a free local TCP port by binding :0 and reading the assigned port. */
|
|
227
|
+
export function pickFreePort() {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const srv = net.createServer();
|
|
230
|
+
srv.once('error', reject);
|
|
231
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
232
|
+
const addr = srv.address();
|
|
233
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
234
|
+
srv.close(() => (port ? resolve(port) : reject(new Error('could not reserve a local port'))));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* `start --host`: open a detached ssh -L tunnel to the remote daemon, verify it
|
|
240
|
+
* answers over TCP, and persist the tunnel state so verbs can reconnect. Returns
|
|
241
|
+
* the state (and leaves the tunnel running in the background).
|
|
242
|
+
*/
|
|
243
|
+
export async function startRemoteTunnel(name) {
|
|
244
|
+
const { target, user, host } = await resolveRemoteDevice(name);
|
|
245
|
+
const remotePort = REMOTE_HELPER_PORT;
|
|
246
|
+
const localPort = await pickFreePort();
|
|
247
|
+
const tunnel = await startSSHTunnel(user, host, localPort, remotePort, { detached: true });
|
|
248
|
+
const tunnelPid = tunnel.pid ?? 0;
|
|
249
|
+
// Verify the daemon answers through the tunnel before we record it. This is
|
|
250
|
+
// the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
|
|
251
|
+
const token = null; // tunnel-gated; the daemon runs token-less
|
|
252
|
+
const prevTcp = process.env.COMPUTER_HELPER_TCP;
|
|
253
|
+
process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${localPort}`;
|
|
254
|
+
const client = openComputerClient();
|
|
255
|
+
let ok = false;
|
|
256
|
+
let probeErr = '';
|
|
257
|
+
try {
|
|
258
|
+
const r = await client.call('list_apps');
|
|
259
|
+
ok = !r.error;
|
|
260
|
+
if (r.error)
|
|
261
|
+
probeErr = `${r.error.code}: ${r.error.message}`;
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
probeErr = e.message;
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await client.close();
|
|
268
|
+
if (prevTcp === undefined)
|
|
269
|
+
delete process.env.COMPUTER_HELPER_TCP;
|
|
270
|
+
else
|
|
271
|
+
process.env.COMPUTER_HELPER_TCP = prevTcp;
|
|
272
|
+
}
|
|
273
|
+
if (!ok) {
|
|
274
|
+
try {
|
|
275
|
+
if (tunnelPid)
|
|
276
|
+
process.kill(tunnelPid);
|
|
277
|
+
}
|
|
278
|
+
catch { /* gone */ }
|
|
279
|
+
throw new Error(`tunnel to '${name}' opened but the daemon did not answer (${probeErr}). ` +
|
|
280
|
+
`Is it installed? Run: agents computer setup --host ${name}`);
|
|
281
|
+
}
|
|
282
|
+
const state = {
|
|
283
|
+
device: name,
|
|
284
|
+
target,
|
|
285
|
+
localPort,
|
|
286
|
+
remotePort,
|
|
287
|
+
tunnelPid,
|
|
288
|
+
token,
|
|
289
|
+
taskName: REMOTE_TASK_NAME,
|
|
290
|
+
startedAt: Date.now(),
|
|
291
|
+
};
|
|
292
|
+
writeRemoteState(state);
|
|
293
|
+
return state;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* `stop --host`: kill the local tunnel, unregister the remote task (best-effort
|
|
297
|
+
* — the box may be offline), and clear the persisted state.
|
|
298
|
+
*/
|
|
299
|
+
export async function stopRemoteHelper(name) {
|
|
300
|
+
const state = readRemoteState(name);
|
|
301
|
+
let tunnelKilled = false;
|
|
302
|
+
if (state?.tunnelPid) {
|
|
303
|
+
try {
|
|
304
|
+
process.kill(state.tunnelPid);
|
|
305
|
+
tunnelKilled = true;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
/* already gone */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
let taskRemoved = false;
|
|
312
|
+
try {
|
|
313
|
+
const { target } = await resolveRemoteDevice(name);
|
|
314
|
+
const res = sshExec(target, encodePowerShell(buildUnregisterTaskScript(REMOTE_TASK_NAME)), { timeoutMs: 60_000 });
|
|
315
|
+
taskRemoved = res.code === 0;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
/* device gone / offline — local teardown still succeeds */
|
|
319
|
+
}
|
|
320
|
+
clearRemoteState(name);
|
|
321
|
+
return { tunnelKilled, taskRemoved };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Point this process's RPC client at a device's live tunnel by setting
|
|
325
|
+
* COMPUTER_HELPER_TCP / COMPUTER_HELPER_TOKEN from persisted state. Called for
|
|
326
|
+
* remote verbs (`apps --host`, `click --host`, …) so the shared
|
|
327
|
+
* openComputerClient() transparently selects the TcpClient transport — no
|
|
328
|
+
* per-verb wiring. Exits with guidance when there is no active tunnel.
|
|
329
|
+
*/
|
|
330
|
+
export function hydrateRemoteEnvFromState(name) {
|
|
331
|
+
const state = readRemoteState(name);
|
|
332
|
+
if (!state) {
|
|
333
|
+
console.error(`No active remote tunnel for '${name}'.`);
|
|
334
|
+
console.error(`Run: agents computer start --host ${name}`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${state.localPort}`;
|
|
338
|
+
if (state.token)
|
|
339
|
+
process.env.COMPUTER_HELPER_TOKEN = state.token;
|
|
340
|
+
// Touch resolveTcpEndpoint so a later platform-gate check sees the endpoint.
|
|
341
|
+
void resolveTcpEndpoint();
|
|
342
|
+
}
|
|
343
|
+
/** Generate a shared-secret token (reserved for token-file provisioning). */
|
|
344
|
+
export function generateToken() {
|
|
345
|
+
return randomBytes(24).toString('hex');
|
|
346
|
+
}
|
|
@@ -68,6 +68,7 @@ export declare const loadTmux: ModuleLoader;
|
|
|
68
68
|
export declare const loadBrowser: ModuleLoader;
|
|
69
69
|
export declare const loadComputer: ModuleLoader;
|
|
70
70
|
export declare const loadHosts: ModuleLoader;
|
|
71
|
+
export declare const loadSsh: ModuleLoader;
|
|
71
72
|
export declare const loadPull: ModuleLoader;
|
|
72
73
|
export declare const loadPush: ModuleLoader;
|
|
73
74
|
export declare const loadRepo: ModuleLoader;
|