@phnx-labs/agents-cli 1.20.29 → 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/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/sessions.js +156 -44
- package/dist/commands/sync.js +70 -14
- 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/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- 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 +11 -0
- package/dist/lib/session/db.js +62 -5
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +81 -0
- package/dist/lib/session/parse.d.ts +15 -0
- package/dist/lib/session/parse.js +22 -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 +9 -0
- 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/state.d.ts +2 -0
- package/dist/lib/state.js +17 -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
|
@@ -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
|
+
}
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -99,6 +99,8 @@ export declare function getSystemWorkflowsDir(): string;
|
|
|
99
99
|
export declare function getUserWorkflowsDir(): string;
|
|
100
100
|
export declare function getUserSecretsDir(): string;
|
|
101
101
|
export declare function getUserPromptcutsPath(): string;
|
|
102
|
+
/** Canonical home anchor (HOME env override or os.homedir()). */
|
|
103
|
+
export declare function getHomeDir(): string;
|
|
102
104
|
/** Bucket root for durable runtime data (~/.agents/.history/). */
|
|
103
105
|
export declare function getHistoryDir(): string;
|
|
104
106
|
/** Bucket root for regenerable runtime data (~/.agents/.cache/). */
|
package/dist/lib/state.js
CHANGED
|
@@ -29,6 +29,20 @@ import * as yaml from 'yaml';
|
|
|
29
29
|
import { ensureLockTarget, atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
30
30
|
import { SEEDED_REGISTRIES } from './types.js';
|
|
31
31
|
const HOME = process.env.HOME ?? os.homedir();
|
|
32
|
+
/**
|
|
33
|
+
* Compare two filesystem paths for identity, resolving symlinks and (on
|
|
34
|
+
* Windows) 8.3 short-name vs long-name divergence via the OS realpath.
|
|
35
|
+
* Falls back to a case-folded normalize when a path doesn't exist on disk.
|
|
36
|
+
*/
|
|
37
|
+
function isSamePath(a, b) {
|
|
38
|
+
try {
|
|
39
|
+
return fs.realpathSync.native(a) === fs.realpathSync.native(b);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
const norm = (p) => process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
|
|
43
|
+
return norm(a) === norm(b);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
32
46
|
// ─── Root directories ─────────────────────────────────────────────────────────
|
|
33
47
|
/** User repo — user-authored resources and agents.yaml. Always-on. */
|
|
34
48
|
const USER_AGENTS_DIR = path.join(HOME, '.agents');
|
|
@@ -161,7 +175,7 @@ export function getProjectAgentsDir(startPath = process.cwd()) {
|
|
|
161
175
|
while (true) {
|
|
162
176
|
const agentsPath = path.join(dir, '.agents');
|
|
163
177
|
if (fs.existsSync(agentsPath) && fs.statSync(agentsPath).isDirectory()) {
|
|
164
|
-
if (agentsPath
|
|
178
|
+
if (!isSamePath(agentsPath, SYSTEM_AGENTS_DIR) && !isSamePath(agentsPath, USER_AGENTS_DIR)) {
|
|
165
179
|
return agentsPath;
|
|
166
180
|
}
|
|
167
181
|
}
|
|
@@ -269,6 +283,8 @@ export function getUserPromptcutsPath() { return USER_PROMPTCUTS_FILE; }
|
|
|
269
283
|
//
|
|
270
284
|
// Top-level dirs hold definitions/configs only; runtime data lives under
|
|
271
285
|
// .history/ (durable) or .cache/ (regenerable). See file header.
|
|
286
|
+
/** Canonical home anchor (HOME env override or os.homedir()). */
|
|
287
|
+
export function getHomeDir() { return HOME; }
|
|
272
288
|
/** Bucket root for durable runtime data (~/.agents/.history/). */
|
|
273
289
|
export function getHistoryDir() { return HISTORY_DIR; }
|
|
274
290
|
/** Bucket root for regenerable runtime data (~/.agents/.cache/). */
|
|
@@ -50,7 +50,17 @@ type Mode = 'plan' | 'edit' | 'auto' | 'skip';
|
|
|
50
50
|
export declare function resolveMode(requestedMode: string | null | undefined, defaultMode?: Mode): Mode;
|
|
51
51
|
/** Ensure Gemini's settings.json has experimental.plan enabled for headless plan mode. */
|
|
52
52
|
export declare function ensureGeminiPlanMode(): Promise<void>;
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Check whether the CLI binary for a given agent type is installed.
|
|
55
|
+
* Returns [available, pathOrError].
|
|
56
|
+
*
|
|
57
|
+
* The agents-managed shims dir (`~/.agents/.cache/shims`) is the canonical
|
|
58
|
+
* install location, so a shim there means installed regardless of the caller's
|
|
59
|
+
* PATH. Non-interactive callers — the menu-bar helper, cron, CI — run with a
|
|
60
|
+
* minimal launchd PATH that omits the shims dir; a bare PATH lookup false-flags
|
|
61
|
+
* every shim-based CLI as "not installed". Check the shim first, PATH second
|
|
62
|
+
* (for CLIs the user installed outside agents-cli).
|
|
63
|
+
*/
|
|
54
64
|
export declare function checkCliAvailable(agentType: AgentType): [boolean, string | null];
|
|
55
65
|
/** Check availability of all known agent CLIs. Returns a map of agent type to install status. */
|
|
56
66
|
export declare function checkAllClis(): Record<string, {
|
package/dist/lib/teams/agents.js
CHANGED
|
@@ -18,7 +18,7 @@ import { findExecutable } from '../platform/index.js';
|
|
|
18
18
|
import { normalizeEvents } from './parsers.js';
|
|
19
19
|
import { debug } from './debug.js';
|
|
20
20
|
import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
|
|
21
|
-
import { getAgentsDir as getSystemAgentsDir } from '../state.js';
|
|
21
|
+
import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
|
|
22
22
|
import { AGENTS } from '../agents.js';
|
|
23
23
|
import { sanitizeProcessEnv } from '../secrets/bundles.js';
|
|
24
24
|
let lastMemoryWarnAt = 0;
|
|
@@ -314,12 +314,26 @@ export async function ensureGeminiPlanMode() {
|
|
|
314
314
|
console.warn('[Swarm] Could not enable Gemini plan mode:', err);
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* Check whether the CLI binary for a given agent type is installed.
|
|
319
|
+
* Returns [available, pathOrError].
|
|
320
|
+
*
|
|
321
|
+
* The agents-managed shims dir (`~/.agents/.cache/shims`) is the canonical
|
|
322
|
+
* install location, so a shim there means installed regardless of the caller's
|
|
323
|
+
* PATH. Non-interactive callers — the menu-bar helper, cron, CI — run with a
|
|
324
|
+
* minimal launchd PATH that omits the shims dir; a bare PATH lookup false-flags
|
|
325
|
+
* every shim-based CLI as "not installed". Check the shim first, PATH second
|
|
326
|
+
* (for CLIs the user installed outside agents-cli).
|
|
327
|
+
*/
|
|
318
328
|
export function checkCliAvailable(agentType) {
|
|
319
329
|
const executable = AGENTS[agentType]?.cliCommand;
|
|
320
330
|
if (!executable) {
|
|
321
331
|
return [false, `Unknown agent type: ${agentType}`];
|
|
322
332
|
}
|
|
333
|
+
const shimPath = path.join(getShimsDir(), executable);
|
|
334
|
+
if (fsSync.existsSync(shimPath)) {
|
|
335
|
+
return [true, shimPath];
|
|
336
|
+
}
|
|
323
337
|
const resolved = findExecutable(executable);
|
|
324
338
|
return resolved
|
|
325
339
|
? [true, resolved]
|
package/dist/lib/types.d.ts
CHANGED
package/dist/lib/versions.d.ts
CHANGED
|
@@ -314,6 +314,25 @@ export interface ResourceDiff {
|
|
|
314
314
|
* Uses filesystem state - no tracking needed.
|
|
315
315
|
*/
|
|
316
316
|
export declare function getResourceDiff(agent: AgentId, version: string): ResourceDiff;
|
|
317
|
+
/**
|
|
318
|
+
* Enumerate the DotAgent repo names that resources can be scoped to:
|
|
319
|
+
* the fixed `project` / `user` / `system` layers plus every enabled extra
|
|
320
|
+
* repo alias. Used to validate `agents sync <agent> --repo <name>`.
|
|
321
|
+
*/
|
|
322
|
+
export declare function listRepoNames(): string[];
|
|
323
|
+
/**
|
|
324
|
+
* Build a ResourceSelection scoped to a single DotAgent repo (`system`,
|
|
325
|
+
* `user`, `project`, or an extra-repo alias). Every resource kind is filtered
|
|
326
|
+
* to the entries whose source layer matches `repo`, reusing the same
|
|
327
|
+
* name→source maps and `source:*` pattern expansion the persisted-pattern
|
|
328
|
+
* sync path uses. Passing the result as an explicit `selection` means the sync
|
|
329
|
+
* touches only that repo's resources — no orphan-sweep of the other layers.
|
|
330
|
+
*
|
|
331
|
+
* `memory` is set to `[]` (not omitted): that empty-array sentinel is what
|
|
332
|
+
* `syncResourcesToVersion`'s `skipMemory` gate keys on to leave the memory
|
|
333
|
+
* file untouched — it's a merge of all layers, not a per-repo artifact.
|
|
334
|
+
*/
|
|
335
|
+
export declare function buildRepoScopedSelection(repo: string, cwd?: string): ResourceSelection;
|
|
317
336
|
/**
|
|
318
337
|
* Sync central resources (~/.agents/) into a specific version's config directory.
|
|
319
338
|
* Copies selected resources from central storage into {versionHome}/.{agent}/.
|
package/dist/lib/versions.js
CHANGED
|
@@ -16,13 +16,12 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
|
-
import * as os from 'os';
|
|
20
19
|
import * as yaml from 'yaml';
|
|
21
20
|
import { exec, execFile } from 'child_process';
|
|
22
21
|
import { promisify } from 'util';
|
|
23
22
|
import chalk from 'chalk';
|
|
24
23
|
import { checkbox, select } from '@inquirer/prompts';
|
|
25
|
-
import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, getSkillsDir, getHooksDir, getResolvedRulesDir, getUserRulesDir, getVersionResources, ensureVersionResourcePatterns, getProjectAgentsDir, getPromptcutsPath, getUserPromptcutsPath, getEnabledExtraRepos, getAgentsDir, getUserAgentsDir, getTrashVersionsDir, getActiveRulesPreset } from './state.js';
|
|
24
|
+
import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, getSkillsDir, getHooksDir, getResolvedRulesDir, getUserRulesDir, getVersionResources, ensureVersionResourcePatterns, getProjectAgentsDir, getPromptcutsPath, getUserPromptcutsPath, getEnabledExtraRepos, getAgentsDir, getUserAgentsDir, getTrashVersionsDir, getActiveRulesPreset, getHomeDir } from './state.js';
|
|
26
25
|
import { defaultPatterns, expandPatterns } from './resource-patterns.js';
|
|
27
26
|
import { listResources } from './resources.js';
|
|
28
27
|
import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath } from './agents.js';
|
|
@@ -30,6 +29,7 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
|
|
|
30
29
|
import { parseMcpServerConfig } from './mcp.js';
|
|
31
30
|
import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
|
|
32
31
|
import { importInstallScriptBinary } from './import.js';
|
|
32
|
+
import { IS_WINDOWS } from './platform/index.js';
|
|
33
33
|
import { supports, explainSkip } from './capabilities.js';
|
|
34
34
|
import { discoverPlugins } from './plugins.js';
|
|
35
35
|
import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from './staleness/index.js';
|
|
@@ -819,12 +819,17 @@ export function getBinaryPath(agent, version) {
|
|
|
819
819
|
return path.join(grokDownloads, `grok-${version}`);
|
|
820
820
|
}
|
|
821
821
|
if (agent === 'droid') {
|
|
822
|
-
// Factory's installer drops a standalone native binary
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
822
|
+
// Factory's installer drops a standalone native binary (no npm package,
|
|
823
|
+
// nothing in node_modules/.bin). The binary is global, not per-version —
|
|
824
|
+
// config isolation rides the ~/.factory symlink switch, not a separate
|
|
825
|
+
// binary per version. Install location is platform-specific:
|
|
826
|
+
// macOS/Linux: ~/.local/bin/droid (curl app.factory.ai/cli | sh)
|
|
827
|
+
// Windows: %USERPROFILE%\bin\droid.exe (irm app.factory.ai/cli/windows | iex)
|
|
828
|
+
// Mirror the shim's `droid` branch so isVersionInstalled/`agents view`
|
|
829
|
+
// agree with what actually executes.
|
|
830
|
+
return IS_WINDOWS
|
|
831
|
+
? path.join(getHomeDir(), 'bin', 'droid.exe')
|
|
832
|
+
: path.join(getHomeDir(), '.local', 'bin', 'droid');
|
|
828
833
|
}
|
|
829
834
|
const versionDir = getVersionDir(agent, version);
|
|
830
835
|
return path.join(versionDir, 'node_modules', '.bin', agentConfig.cliCommand);
|
|
@@ -1302,7 +1307,7 @@ export function removeVersion(agent, version) {
|
|
|
1302
1307
|
// Clean up dangling config symlink if it pointed to the removed version
|
|
1303
1308
|
const symlinkVersion = getConfigSymlinkVersion(agent);
|
|
1304
1309
|
if (symlinkVersion === version) {
|
|
1305
|
-
const configPath = path.join(
|
|
1310
|
+
const configPath = path.join(getHomeDir(), agentConfigDirName(agent));
|
|
1306
1311
|
try {
|
|
1307
1312
|
fs.unlinkSync(configPath);
|
|
1308
1313
|
}
|
|
@@ -1636,6 +1641,68 @@ export function getResourceDiff(agent, version) {
|
|
|
1636
1641
|
diff.hooks.dangling.length + diff.memory.dangling.length;
|
|
1637
1642
|
return diff;
|
|
1638
1643
|
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Enumerate the DotAgent repo names that resources can be scoped to:
|
|
1646
|
+
* the fixed `project` / `user` / `system` layers plus every enabled extra
|
|
1647
|
+
* repo alias. Used to validate `agents sync <agent> --repo <name>`.
|
|
1648
|
+
*/
|
|
1649
|
+
export function listRepoNames() {
|
|
1650
|
+
return ['project', 'user', 'system', ...getEnabledExtraRepos().map(e => e.alias)];
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Build the name→source-layer map for one resource kind, the input
|
|
1654
|
+
* `expandPatterns` matches `source:*` patterns against. This is the single
|
|
1655
|
+
* source of truth for how each kind attributes its source layer:
|
|
1656
|
+
* - commands/skills/hooks/subagents → real layer from `listResources`
|
|
1657
|
+
* - permissions → always the system repo
|
|
1658
|
+
* - mcp → project vs user scope preserved
|
|
1659
|
+
* - plugins/workflows → user repo
|
|
1660
|
+
* Both the persisted-pattern sync path and `buildRepoScopedSelection` use it
|
|
1661
|
+
* so the attribution can't drift between the two.
|
|
1662
|
+
*/
|
|
1663
|
+
function resourceSourceMap(kind, cwd, available) {
|
|
1664
|
+
switch (kind) {
|
|
1665
|
+
case 'commands':
|
|
1666
|
+
case 'skills':
|
|
1667
|
+
case 'hooks':
|
|
1668
|
+
case 'subagents':
|
|
1669
|
+
return new Map(listResources(kind, cwd).map(r => [r.name, r.source]));
|
|
1670
|
+
case 'permissions':
|
|
1671
|
+
return new Map(available.permissions.map(n => [n, 'system']));
|
|
1672
|
+
case 'mcp':
|
|
1673
|
+
return new Map(getScopedMcpResources(cwd).map(r => [r.name, r.scope]));
|
|
1674
|
+
case 'plugins':
|
|
1675
|
+
return new Map(available.plugins.map(n => [n, 'user']));
|
|
1676
|
+
case 'workflows':
|
|
1677
|
+
return new Map(available.workflows.map(n => [n, 'user']));
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Build a ResourceSelection scoped to a single DotAgent repo (`system`,
|
|
1682
|
+
* `user`, `project`, or an extra-repo alias). Every resource kind is filtered
|
|
1683
|
+
* to the entries whose source layer matches `repo`, reusing the same
|
|
1684
|
+
* name→source maps and `source:*` pattern expansion the persisted-pattern
|
|
1685
|
+
* sync path uses. Passing the result as an explicit `selection` means the sync
|
|
1686
|
+
* touches only that repo's resources — no orphan-sweep of the other layers.
|
|
1687
|
+
*
|
|
1688
|
+
* `memory` is set to `[]` (not omitted): that empty-array sentinel is what
|
|
1689
|
+
* `syncResourcesToVersion`'s `skipMemory` gate keys on to leave the memory
|
|
1690
|
+
* file untouched — it's a merge of all layers, not a per-repo artifact.
|
|
1691
|
+
*/
|
|
1692
|
+
export function buildRepoScopedSelection(repo, cwd = process.cwd()) {
|
|
1693
|
+
const patterns = [`${repo}:*`];
|
|
1694
|
+
const available = getAvailableResources(cwd);
|
|
1695
|
+
const selection = {};
|
|
1696
|
+
const kinds = ['commands', 'skills', 'hooks', 'subagents', 'permissions', 'mcp', 'plugins', 'workflows'];
|
|
1697
|
+
for (const kind of kinds) {
|
|
1698
|
+
const names = expandPatterns(patterns, resourceSourceMap(kind, cwd, available));
|
|
1699
|
+
if (names.length > 0)
|
|
1700
|
+
selection[kind] = names;
|
|
1701
|
+
}
|
|
1702
|
+
// Empty-array sentinel → skip the memory writer (see skipMemory below).
|
|
1703
|
+
selection.memory = [];
|
|
1704
|
+
return selection;
|
|
1705
|
+
}
|
|
1639
1706
|
/**
|
|
1640
1707
|
* Sync central resources (~/.agents/) into a specific version's config directory.
|
|
1641
1708
|
* Copies selected resources from central storage into {versionHome}/.{agent}/.
|
|
@@ -1701,28 +1768,21 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
|
|
|
1701
1768
|
const patterns = vr[type];
|
|
1702
1769
|
if (!Array.isArray(patterns) || patterns.length === 0)
|
|
1703
1770
|
continue;
|
|
1704
|
-
|
|
1705
|
-
patternSelection[type] = expandPatterns(patterns, sourceMap);
|
|
1771
|
+
patternSelection[type] = expandPatterns(patterns, resourceSourceMap(kind, cwd, available));
|
|
1706
1772
|
}
|
|
1707
|
-
// permissions
|
|
1773
|
+
// permissions / mcp / plugins / workflows: source attribution lives in
|
|
1774
|
+
// resourceSourceMap so it can't drift from buildRepoScopedSelection.
|
|
1708
1775
|
if (Array.isArray(vr.permissions) && vr.permissions.length > 0) {
|
|
1709
|
-
|
|
1710
|
-
patternSelection.permissions = expandPatterns(vr.permissions, permMap);
|
|
1776
|
+
patternSelection.permissions = expandPatterns(vr.permissions, resourceSourceMap('permissions', cwd, available));
|
|
1711
1777
|
}
|
|
1712
|
-
// mcp: pattern matching must preserve project vs user scope.
|
|
1713
1778
|
if (Array.isArray(vr.mcp) && vr.mcp.length > 0) {
|
|
1714
|
-
|
|
1715
|
-
patternSelection.mcp = expandPatterns(vr.mcp, mcpMap);
|
|
1779
|
+
patternSelection.mcp = expandPatterns(vr.mcp, resourceSourceMap('mcp', cwd, available));
|
|
1716
1780
|
}
|
|
1717
|
-
// plugins: treat all as 'user' source for now.
|
|
1718
1781
|
if (Array.isArray(vr.plugins) && vr.plugins.length > 0) {
|
|
1719
|
-
|
|
1720
|
-
patternSelection.plugins = expandPatterns(vr.plugins, pluginMap);
|
|
1782
|
+
patternSelection.plugins = expandPatterns(vr.plugins, resourceSourceMap('plugins', cwd, available));
|
|
1721
1783
|
}
|
|
1722
|
-
// workflows: treat all as 'user' source.
|
|
1723
1784
|
if (Array.isArray(vr.workflows) && vr.workflows.length > 0) {
|
|
1724
|
-
|
|
1725
|
-
patternSelection.workflows = expandPatterns(vr.workflows, workflowMap);
|
|
1785
|
+
patternSelection.workflows = expandPatterns(vr.workflows, resourceSourceMap('workflows', cwd, available));
|
|
1726
1786
|
}
|
|
1727
1787
|
// memory is not pattern-controlled (rulesPreset handles it) — always sync.
|
|
1728
1788
|
patternSelection.memory = 'all';
|
|
@@ -2071,7 +2131,7 @@ export function getEffectiveHome(agentId) {
|
|
|
2071
2131
|
if (resolved && isVersionInstalled(agentId, resolved)) {
|
|
2072
2132
|
return getVersionHomePath(agentId, resolved);
|
|
2073
2133
|
}
|
|
2074
|
-
return
|
|
2134
|
+
return getHomeDir();
|
|
2075
2135
|
}
|
|
2076
2136
|
/**
|
|
2077
2137
|
* Thrown when the user references an agent@version that is not installed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.30",
|
|
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",
|