@phnx-labs/agents-cli 1.20.62 → 1.20.63
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 +45 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +41 -0
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +58 -18
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -419,6 +419,8 @@ export async function runDaemon() {
|
|
|
419
419
|
log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
|
|
420
420
|
(r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
|
|
421
421
|
}
|
|
422
|
+
if (r.warnings.length)
|
|
423
|
+
log('WARN', `sessions sync: ${r.warnings[0]}`);
|
|
422
424
|
}
|
|
423
425
|
catch (err) {
|
|
424
426
|
log('ERROR', `sessions sync failed: ${err.message}`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-wide device operations — pick online targets and run a command on each.
|
|
3
|
+
*
|
|
4
|
+
* Used by `agents fleet update` / `agents fleet run` (aliases of the same
|
|
5
|
+
* subcommands under `agents devices`). Offline devices are skipped with a
|
|
6
|
+
* reason so a single dead node never blocks the rest of the rollout. Per-device
|
|
7
|
+
* throws (misconfigured auth, etc.) become `failed` rows — they never abort
|
|
8
|
+
* the remaining devices.
|
|
9
|
+
*/
|
|
10
|
+
import type { DeviceProfile, DeviceRegistry } from './registry.js';
|
|
11
|
+
export type FleetSkipReason = 'offline' | 'no-address';
|
|
12
|
+
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
|
+
export declare const FLEET_VERSION_RE: RegExp;
|
|
14
|
+
export interface FleetTarget {
|
|
15
|
+
device: DeviceProfile;
|
|
16
|
+
/** When set, this device is not reached (skip with reason). */
|
|
17
|
+
skip?: FleetSkipReason;
|
|
18
|
+
}
|
|
19
|
+
export interface FleetRunResult {
|
|
20
|
+
name: string;
|
|
21
|
+
status: 'ok' | 'failed' | 'skipped';
|
|
22
|
+
code: number | null;
|
|
23
|
+
reason?: FleetSkipReason | string;
|
|
24
|
+
/** Truncated combined stderr/stdout for failures. */
|
|
25
|
+
detail?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Classify each registered device for a fleet operation.
|
|
29
|
+
*
|
|
30
|
+
* - Tailscale-offline → skip `offline`
|
|
31
|
+
* - No address → skip `no-address`
|
|
32
|
+
* - Everything else is a target (including this machine, reached over ssh when
|
|
33
|
+
* it has a registry address — same path as any other box).
|
|
34
|
+
*/
|
|
35
|
+
export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
|
|
36
|
+
/** Human label for a skip reason. */
|
|
37
|
+
export declare function skipLabel(reason: FleetSkipReason): string;
|
|
38
|
+
/**
|
|
39
|
+
* Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
|
|
40
|
+
* Captures stdout/stderr (not inherited) so the fleet table can summarize.
|
|
41
|
+
* Throws from buildSshInvocation are returned as a non-zero result so a single
|
|
42
|
+
* misconfigured device cannot abort the fleet loop.
|
|
43
|
+
*/
|
|
44
|
+
export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?: {
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}): {
|
|
47
|
+
code: number | null;
|
|
48
|
+
stdout: string;
|
|
49
|
+
stderr: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
|
|
53
|
+
* Rejects anything that is not a plain npm version/tag token so a version pin
|
|
54
|
+
* cannot inject shell metacharacters into the remote command line.
|
|
55
|
+
*/
|
|
56
|
+
export declare function upgradeCommand(version?: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Execute a command across planned targets. Pure orchestration over
|
|
59
|
+
* {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
|
|
60
|
+
* the runner are recorded as `failed` so one bad device never aborts the rest.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-wide device operations — pick online targets and run a command on each.
|
|
3
|
+
*
|
|
4
|
+
* Used by `agents fleet update` / `agents fleet run` (aliases of the same
|
|
5
|
+
* subcommands under `agents devices`). Offline devices are skipped with a
|
|
6
|
+
* reason so a single dead node never blocks the rest of the rollout. Per-device
|
|
7
|
+
* throws (misconfigured auth, etc.) become `failed` rows — they never abort
|
|
8
|
+
* the remaining devices.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
|
|
12
|
+
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
|
+
export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
14
|
+
/**
|
|
15
|
+
* Classify each registered device for a fleet operation.
|
|
16
|
+
*
|
|
17
|
+
* - Tailscale-offline → skip `offline`
|
|
18
|
+
* - No address → skip `no-address`
|
|
19
|
+
* - Everything else is a target (including this machine, reached over ssh when
|
|
20
|
+
* it has a registry address — same path as any other box).
|
|
21
|
+
*/
|
|
22
|
+
export function planFleetTargets(reg) {
|
|
23
|
+
const names = Object.keys(reg).sort();
|
|
24
|
+
return names.map((name) => {
|
|
25
|
+
const device = reg[name];
|
|
26
|
+
if (device.tailscale && !device.tailscale.online) {
|
|
27
|
+
return { device, skip: 'offline' };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
sshTargetFor(device);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { device, skip: 'no-address' };
|
|
34
|
+
}
|
|
35
|
+
return { device };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Human label for a skip reason. */
|
|
39
|
+
export function skipLabel(reason) {
|
|
40
|
+
switch (reason) {
|
|
41
|
+
case 'offline':
|
|
42
|
+
return 'offline';
|
|
43
|
+
case 'no-address':
|
|
44
|
+
return 'no address';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
|
|
49
|
+
* Captures stdout/stderr (not inherited) so the fleet table can summarize.
|
|
50
|
+
* Throws from buildSshInvocation are returned as a non-zero result so a single
|
|
51
|
+
* misconfigured device cannot abort the fleet loop.
|
|
52
|
+
*/
|
|
53
|
+
export function runOnDevice(device, cmd, opts = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const shim = writeAskpassShim();
|
|
56
|
+
const { args, env } = buildSshInvocation(device, cmd, shim);
|
|
57
|
+
const res = spawnSync('ssh', args, {
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
env: { ...process.env, ...env },
|
|
60
|
+
timeout: opts.timeoutMs ?? 600_000,
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
code: res.status,
|
|
64
|
+
stdout: res.stdout?.toString() ?? '',
|
|
65
|
+
stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
code: 1,
|
|
71
|
+
stdout: '',
|
|
72
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
|
|
78
|
+
* Rejects anything that is not a plain npm version/tag token so a version pin
|
|
79
|
+
* cannot inject shell metacharacters into the remote command line.
|
|
80
|
+
*/
|
|
81
|
+
export function upgradeCommand(version) {
|
|
82
|
+
if (version !== undefined && version !== '') {
|
|
83
|
+
if (!FLEET_VERSION_RE.test(version)) {
|
|
84
|
+
throw new Error(`Invalid version '${version}'. Use a semver or dist-tag (letters, digits, . _ - only).`);
|
|
85
|
+
}
|
|
86
|
+
return ['agents', 'upgrade', version, '--yes'];
|
|
87
|
+
}
|
|
88
|
+
return ['agents', 'upgrade', '--yes'];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Execute a command across planned targets. Pure orchestration over
|
|
92
|
+
* {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
|
|
93
|
+
* the runner are recorded as `failed` so one bad device never aborts the rest.
|
|
94
|
+
*/
|
|
95
|
+
export function runFleet(targets, cmd, runner = runOnDevice) {
|
|
96
|
+
const results = [];
|
|
97
|
+
for (const t of targets) {
|
|
98
|
+
if (t.skip) {
|
|
99
|
+
results.push({
|
|
100
|
+
name: t.device.name,
|
|
101
|
+
status: 'skipped',
|
|
102
|
+
code: null,
|
|
103
|
+
reason: t.skip,
|
|
104
|
+
});
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const res = runner(t.device, cmd);
|
|
109
|
+
const ok = res.code === 0;
|
|
110
|
+
const detail = (res.stderr || res.stdout).trim().slice(0, 200);
|
|
111
|
+
results.push({
|
|
112
|
+
name: t.device.name,
|
|
113
|
+
status: ok ? 'ok' : 'failed',
|
|
114
|
+
code: res.code,
|
|
115
|
+
detail: ok ? undefined : detail || undefined,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
results.push({
|
|
120
|
+
name: t.device.name,
|
|
121
|
+
status: 'failed',
|
|
122
|
+
code: 1,
|
|
123
|
+
detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return results;
|
|
128
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
|
|
2
|
+
export type FunnelPort = typeof FUNNEL_PORTS[number];
|
|
3
|
+
export declare function parseFunnelPort(value: string | number): FunnelPort;
|
|
4
|
+
export declare function buildFunnelStatusCommand(): string;
|
|
5
|
+
export declare function buildFunnelUpCommand(publicPort: FunnelPort, localPort: number): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { shellQuote } from './ssh-exec.js';
|
|
2
|
+
export const FUNNEL_PORTS = [443, 8443, 10000];
|
|
3
|
+
export function parseFunnelPort(value) {
|
|
4
|
+
const port = typeof value === 'number' ? value : Number.parseInt(value, 10);
|
|
5
|
+
if (FUNNEL_PORTS.includes(port))
|
|
6
|
+
return port;
|
|
7
|
+
throw new Error(`Tailscale Funnel public port must be one of: ${FUNNEL_PORTS.join(', ')}`);
|
|
8
|
+
}
|
|
9
|
+
export function buildFunnelStatusCommand() {
|
|
10
|
+
return 'tailscale funnel status';
|
|
11
|
+
}
|
|
12
|
+
export function buildFunnelUpCommand(publicPort, localPort) {
|
|
13
|
+
if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
|
|
14
|
+
throw new Error('Local port must be between 1 and 65535');
|
|
15
|
+
}
|
|
16
|
+
return [
|
|
17
|
+
'tailscale',
|
|
18
|
+
'funnel',
|
|
19
|
+
'--bg',
|
|
20
|
+
`--https=${publicPort}`,
|
|
21
|
+
`http://localhost:${localPort}`,
|
|
22
|
+
].map(shellQuote).join(' ');
|
|
23
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -76,13 +76,24 @@ export declare function setRemoteUrl(repoPath: string, url: string): Promise<voi
|
|
|
76
76
|
* Check if a GitHub repo exists.
|
|
77
77
|
*/
|
|
78
78
|
export declare function checkGitHubRepoExists(owner: string, repo: string): Promise<boolean>;
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
*/
|
|
82
|
-
export declare function commitAndPush(repoPath: string, message: string): Promise<{
|
|
79
|
+
/** Result of {@link commitAndPush}. */
|
|
80
|
+
export type CommitAndPushResult = {
|
|
83
81
|
success: boolean;
|
|
84
82
|
error?: string;
|
|
85
|
-
|
|
83
|
+
/** Human detail for success: "already up to date", "pushed abc..def", "committed and pushed …". */
|
|
84
|
+
detail?: string;
|
|
85
|
+
branch?: string;
|
|
86
|
+
committed?: boolean;
|
|
87
|
+
pushed?: boolean;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Commit (if dirty) and push a repo.
|
|
91
|
+
*
|
|
92
|
+
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
93
|
+
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
94
|
+
* there is nothing to commit.
|
|
95
|
+
*/
|
|
96
|
+
export declare function commitAndPush(repoPath: string, message: string): Promise<CommitAndPushResult>;
|
|
86
97
|
/**
|
|
87
98
|
* Check if repo has uncommitted changes.
|
|
88
99
|
*/
|
|
@@ -166,11 +177,16 @@ export declare function displayHomePath(dir: string): string;
|
|
|
166
177
|
/**
|
|
167
178
|
* Pull changes in an existing repo.
|
|
168
179
|
* Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
|
|
180
|
+
*
|
|
181
|
+
* Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
|
|
182
|
+
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
183
|
+
* divergent branches".
|
|
169
184
|
*/
|
|
170
185
|
export declare function pullRepo(dir: string): Promise<{
|
|
171
186
|
success: boolean;
|
|
172
187
|
commit: string;
|
|
173
188
|
error?: string;
|
|
189
|
+
branch?: string;
|
|
174
190
|
}>;
|
|
175
191
|
/**
|
|
176
192
|
* Rebase a repo onto its remote, optionally pushing local commits back up.
|
package/dist/lib/git.js
CHANGED
|
@@ -366,23 +366,67 @@ export async function checkGitHubRepoExists(owner, repo) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
/**
|
|
369
|
-
* Commit and push
|
|
369
|
+
* Commit (if dirty) and push a repo.
|
|
370
|
+
*
|
|
371
|
+
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
372
|
+
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
373
|
+
* there is nothing to commit.
|
|
370
374
|
*/
|
|
371
375
|
export async function commitAndPush(repoPath, message) {
|
|
372
376
|
try {
|
|
373
377
|
const git = simpleGit(repoPath);
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
let status = await git.status();
|
|
379
|
+
const branch = status.current || 'main';
|
|
380
|
+
let committed = false;
|
|
381
|
+
if (status.files.length > 0) {
|
|
382
|
+
await git.add('-A');
|
|
383
|
+
await git.commit(message);
|
|
384
|
+
committed = true;
|
|
385
|
+
status = await git.status();
|
|
386
|
+
}
|
|
387
|
+
const ahead = status.ahead ?? 0;
|
|
388
|
+
if (!committed && ahead === 0) {
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
detail: 'already up to date',
|
|
392
|
+
branch,
|
|
393
|
+
committed: false,
|
|
394
|
+
pushed: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Capture remote tip before push for a real ref range in the detail string.
|
|
398
|
+
let before = '';
|
|
399
|
+
try {
|
|
400
|
+
before = (await git.raw(['rev-parse', '--short=8', `origin/${branch}`])).trim();
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* origin/<branch> may not exist yet (first push) */
|
|
378
404
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
405
|
+
await git.push('origin', branch);
|
|
406
|
+
let after = '';
|
|
407
|
+
try {
|
|
408
|
+
after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
after = 'unknown';
|
|
412
|
+
}
|
|
413
|
+
const range = before && after && before !== after
|
|
414
|
+
? `${before}..${after}`
|
|
415
|
+
: after || undefined;
|
|
416
|
+
const detail = committed
|
|
417
|
+
? range
|
|
418
|
+
? `committed and pushed ${range}`
|
|
419
|
+
: 'committed and pushed'
|
|
420
|
+
: range
|
|
421
|
+
? `pushed ${range}`
|
|
422
|
+
: 'pushed';
|
|
423
|
+
return {
|
|
424
|
+
success: true,
|
|
425
|
+
detail,
|
|
426
|
+
branch,
|
|
427
|
+
committed,
|
|
428
|
+
pushed: true,
|
|
429
|
+
};
|
|
386
430
|
}
|
|
387
431
|
catch (err) {
|
|
388
432
|
return { success: false, error: err.message };
|
|
@@ -630,6 +674,10 @@ export function displayHomePath(dir) {
|
|
|
630
674
|
/**
|
|
631
675
|
* Pull changes in an existing repo.
|
|
632
676
|
* Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
|
|
677
|
+
*
|
|
678
|
+
* Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
|
|
679
|
+
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
680
|
+
* divergent branches".
|
|
633
681
|
*/
|
|
634
682
|
export async function pullRepo(dir) {
|
|
635
683
|
try {
|
|
@@ -642,13 +690,15 @@ export async function pullRepo(dir) {
|
|
|
642
690
|
error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
|
|
643
691
|
};
|
|
644
692
|
}
|
|
645
|
-
|
|
646
|
-
await git.
|
|
693
|
+
const branch = status.current || 'main';
|
|
694
|
+
await git.fetch('origin');
|
|
695
|
+
await git.pull('origin', branch, { '--rebase': 'true' });
|
|
647
696
|
installGithooksSymlinks(dir);
|
|
648
697
|
const log = await git.log({ maxCount: 1 });
|
|
649
698
|
return {
|
|
650
699
|
success: true,
|
|
651
700
|
commit: log.latest?.hash.slice(0, 8) || 'unknown',
|
|
701
|
+
branch,
|
|
652
702
|
};
|
|
653
703
|
}
|
|
654
704
|
catch (err) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential provisioning for `agents run --host --copy-creds`.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the lease flow's runtime detection + credential-script builder so a
|
|
5
|
+
* persistent host can boot logged-in the same way an ephemeral leased box does.
|
|
6
|
+
* Unlike `--lease`, a host is persistent, so copying tokens is strictly opt-in
|
|
7
|
+
* per run and we shred the files after the run to bound the credential window.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentId } from '../types.js';
|
|
10
|
+
import { type DetectedRuntime } from '../crabbox/runtimes.js';
|
|
11
|
+
export interface HostCredentials {
|
|
12
|
+
runtimes: AgentId[];
|
|
13
|
+
detected: DetectedRuntime[];
|
|
14
|
+
claudeCredentialsJson?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the setup (write credential files) and teardown (shred them) scripts for
|
|
18
|
+
* a host run. Returns shell snippets meant to be run on the remote host.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildHostCredentialScript(opts: HostCredentials): {
|
|
21
|
+
setup: string;
|
|
22
|
+
teardown: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a remote command so credentials are written before it runs and shredded
|
|
26
|
+
* after it exits, regardless of success or failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function wrapHostCommandWithCredentials(innerCommand: string, opts: HostCredentials): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential provisioning for `agents run --host --copy-creds`.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the lease flow's runtime detection + credential-script builder so a
|
|
5
|
+
* persistent host can boot logged-in the same way an ephemeral leased box does.
|
|
6
|
+
* Unlike `--lease`, a host is persistent, so copying tokens is strictly opt-in
|
|
7
|
+
* per run and we shred the files after the run to bound the credential window.
|
|
8
|
+
*/
|
|
9
|
+
import { buildCredentialScript, CLAUDE_TOKEN_REMOTE } from '../crabbox/runtimes.js';
|
|
10
|
+
function getShredPaths(runtimes) {
|
|
11
|
+
const pathsById = {
|
|
12
|
+
claude: ['.claude.json', CLAUDE_TOKEN_REMOTE],
|
|
13
|
+
codex: ['.codex/auth.json'],
|
|
14
|
+
gemini: ['.gemini/google_accounts.json'],
|
|
15
|
+
grok: ['.grok/auth.json'],
|
|
16
|
+
};
|
|
17
|
+
return runtimes.flatMap((id) => pathsById[id] ?? []);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the setup (write credential files) and teardown (shred them) scripts for
|
|
21
|
+
* a host run. Returns shell snippets meant to be run on the remote host.
|
|
22
|
+
*/
|
|
23
|
+
export function buildHostCredentialScript(opts) {
|
|
24
|
+
const setup = buildCredentialScript(opts.runtimes, opts.detected, {
|
|
25
|
+
claudeCredentialsJson: opts.claudeCredentialsJson,
|
|
26
|
+
});
|
|
27
|
+
const teardown = getShredPaths(opts.runtimes)
|
|
28
|
+
.map((p) => `rm -f "$HOME/${p}" 2>/dev/null || true`)
|
|
29
|
+
.join('\n');
|
|
30
|
+
return { setup, teardown };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a remote command so credentials are written before it runs and shredded
|
|
34
|
+
* after it exits, regardless of success or failure.
|
|
35
|
+
*/
|
|
36
|
+
export function wrapHostCommandWithCredentials(innerCommand, opts) {
|
|
37
|
+
const { setup, teardown } = buildHostCredentialScript(opts);
|
|
38
|
+
return [
|
|
39
|
+
'set -uo pipefail',
|
|
40
|
+
setup,
|
|
41
|
+
innerCommand,
|
|
42
|
+
'rc=$?',
|
|
43
|
+
teardown,
|
|
44
|
+
'exit $rc',
|
|
45
|
+
]
|
|
46
|
+
.filter((l) => l.length > 0)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Host } from './types.js';
|
|
12
12
|
import { type HostTask } from './tasks.js';
|
|
13
|
+
import { type HostCredentials } from './credentials.js';
|
|
13
14
|
/**
|
|
14
15
|
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
15
16
|
*
|
|
@@ -36,6 +37,26 @@ export interface DispatchResult {
|
|
|
36
37
|
}
|
|
37
38
|
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
38
39
|
export declare function terminateDispatchedTask(task: HostTask): void;
|
|
40
|
+
/**
|
|
41
|
+
* Build the remote shell used by {@link stopDispatchedTask}. Exported for
|
|
42
|
+
* unit tests — the keep-log / no-clobber contract lives in this script.
|
|
43
|
+
*
|
|
44
|
+
* Protocol (printed to stdout for the local caller):
|
|
45
|
+
* - `SIGNALED` — process group was live; SIGTERM/KILL applied; wrote 143
|
|
46
|
+
* - `ALREADY` + code — group gone; adopted existing `.exit` (never overwrite)
|
|
47
|
+
* - `GONE` — group gone and no `.exit`; write 143 as the local stop outcome
|
|
48
|
+
* Exit 1 if the group is still alive after TERM/KILL (can't stop it).
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildStopRemoteCommand(pid: number, remoteExit: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
53
|
+
*
|
|
54
|
+
* Unlike {@link terminateDispatchedTask} (rollback cleanup after a failed
|
|
55
|
+
* persist), this keeps the remote log so `agents hosts logs <id>` still works,
|
|
56
|
+
* writes a terminal `.exit` marker only when we actually stopped a live group
|
|
57
|
+
* (or no code existed), and never clobbers a real completed-run exit code.
|
|
58
|
+
*/
|
|
59
|
+
export declare function stopDispatchedTask(task: HostTask): HostTask;
|
|
39
60
|
export interface DispatchOptions {
|
|
40
61
|
agent: string;
|
|
41
62
|
prompt: string;
|
|
@@ -76,6 +97,8 @@ export interface DispatchOptions {
|
|
|
76
97
|
/** Stream progress and block until completion (default true). */
|
|
77
98
|
follow?: boolean;
|
|
78
99
|
timeoutMs?: number;
|
|
100
|
+
/** Copy runtime credentials to the host before the run and shred them after. */
|
|
101
|
+
copyCreds?: HostCredentials;
|
|
79
102
|
}
|
|
80
103
|
/**
|
|
81
104
|
* Build the remote `agents run …` argv for a host dispatch. Pure so the
|
|
@@ -116,6 +139,8 @@ export interface InteractiveDispatchOptions {
|
|
|
116
139
|
raw?: boolean;
|
|
117
140
|
/** Forward `--interactive` to the remote so a prompt-bearing run still starts the TUI. */
|
|
118
141
|
forceInteractive?: boolean;
|
|
142
|
+
/** Copy runtime credentials to the host before the run and shred them after. */
|
|
143
|
+
copyCreds?: HostCredentials;
|
|
119
144
|
}
|
|
120
145
|
/**
|
|
121
146
|
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|
|
@@ -16,6 +16,7 @@ import { remoteShellFor } from './remote-cmd.js';
|
|
|
16
16
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
17
17
|
import { saveTask, updateTask, terminalPatch } from './tasks.js';
|
|
18
18
|
import { followHostTask } from './progress.js';
|
|
19
|
+
import { wrapHostCommandWithCredentials } from './credentials.js';
|
|
19
20
|
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
20
21
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
21
22
|
// injection-safe to interpolate unquoted into remote commands.
|
|
@@ -91,6 +92,64 @@ export function terminateDispatchedTask(task) {
|
|
|
91
92
|
terminateRemoteLaunch(task);
|
|
92
93
|
updateTask(task.id, terminalPatch(143));
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Build the remote shell used by {@link stopDispatchedTask}. Exported for
|
|
97
|
+
* unit tests — the keep-log / no-clobber contract lives in this script.
|
|
98
|
+
*
|
|
99
|
+
* Protocol (printed to stdout for the local caller):
|
|
100
|
+
* - `SIGNALED` — process group was live; SIGTERM/KILL applied; wrote 143
|
|
101
|
+
* - `ALREADY` + code — group gone; adopted existing `.exit` (never overwrite)
|
|
102
|
+
* - `GONE` — group gone and no `.exit`; write 143 as the local stop outcome
|
|
103
|
+
* Exit 1 if the group is still alive after TERM/KILL (can't stop it).
|
|
104
|
+
*/
|
|
105
|
+
export function buildStopRemoteCommand(pid, remoteExit) {
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
107
|
+
throw new Error(`Invalid remote task pid: ${pid}`);
|
|
108
|
+
}
|
|
109
|
+
// Only force-write 143 when we actually signaled a live group (or nothing
|
|
110
|
+
// left a code). Never `echo 143` over a real completed-run exit code.
|
|
111
|
+
return (`if kill -TERM -- -${pid} 2>/dev/null; then ` +
|
|
112
|
+
`sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
|
|
113
|
+
`echo 143 > ${remoteExit}; echo SIGNALED; ` +
|
|
114
|
+
`elif kill -0 -- -${pid} 2>/dev/null; then ` +
|
|
115
|
+
`exit 1; ` +
|
|
116
|
+
`else ` +
|
|
117
|
+
`code=$(cat ${remoteExit} 2>/dev/null | tr -d '[:space:]'); ` +
|
|
118
|
+
`if [ -n "$code" ]; then echo "ALREADY $code"; ` +
|
|
119
|
+
`else echo 143 > ${remoteExit}; echo GONE; fi; ` +
|
|
120
|
+
`fi`);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
124
|
+
*
|
|
125
|
+
* Unlike {@link terminateDispatchedTask} (rollback cleanup after a failed
|
|
126
|
+
* persist), this keeps the remote log so `agents hosts logs <id>` still works,
|
|
127
|
+
* writes a terminal `.exit` marker only when we actually stopped a live group
|
|
128
|
+
* (or no code existed), and never clobbers a real completed-run exit code.
|
|
129
|
+
*/
|
|
130
|
+
export function stopDispatchedTask(task) {
|
|
131
|
+
if (task.status !== 'running') {
|
|
132
|
+
throw new Error(`Task ${task.id} is already ${task.status}`);
|
|
133
|
+
}
|
|
134
|
+
if (!task.pid) {
|
|
135
|
+
throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
|
|
136
|
+
}
|
|
137
|
+
const command = buildStopRemoteCommand(task.pid, task.remoteExit);
|
|
138
|
+
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
|
|
139
|
+
if (result.code !== 0) {
|
|
140
|
+
throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
|
|
141
|
+
`${(result.stderr || result.stdout).trim() || 'ssh error'}`);
|
|
142
|
+
}
|
|
143
|
+
const line = result.stdout.trim().split('\n').pop() ?? '';
|
|
144
|
+
let code = 143;
|
|
145
|
+
if (line.startsWith('ALREADY ')) {
|
|
146
|
+
const parsed = parseInt(line.slice('ALREADY '.length), 10);
|
|
147
|
+
if (Number.isFinite(parsed))
|
|
148
|
+
code = parsed;
|
|
149
|
+
}
|
|
150
|
+
// SIGNALED / GONE / ALREADY all end with a terminal local record.
|
|
151
|
+
return updateTask(task.id, terminalPatch(code)) ?? { ...task, ...terminalPatch(code) };
|
|
152
|
+
}
|
|
94
153
|
/**
|
|
95
154
|
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
96
155
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
@@ -117,7 +176,10 @@ async function launchDetached(host, target, opts) {
|
|
|
117
176
|
// Inner command run under a login shell so PATH resolves `agents`.
|
|
118
177
|
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
119
178
|
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
120
|
-
|
|
179
|
+
let inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
180
|
+
if (opts.copyCreds) {
|
|
181
|
+
inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
|
|
182
|
+
}
|
|
121
183
|
// Outer: ensure dir, launch the login-shell wrapper as a new process-group
|
|
122
184
|
// leader, and print that leader PID.
|
|
123
185
|
const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
|
|
@@ -268,7 +330,10 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
268
330
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
269
331
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
270
332
|
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
271
|
-
|
|
333
|
+
let remoteCmd = `${cwd}${invocation}`;
|
|
334
|
+
if (opts.copyCreds) {
|
|
335
|
+
remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
|
|
336
|
+
}
|
|
272
337
|
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
273
338
|
}
|
|
274
339
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
@@ -288,6 +353,7 @@ export async function dispatchToHost(host, opts) {
|
|
|
288
353
|
// On resume the remote session keeps its existing id; record that id so the
|
|
289
354
|
// task stays mapped to the same session.
|
|
290
355
|
sessionId: opts.resume ?? opts.sessionId,
|
|
356
|
+
copyCreds: opts.copyCreds,
|
|
291
357
|
});
|
|
292
358
|
}
|
|
293
359
|
/**
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
-
* `agents <command>` on a remote host instead of locally
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
-
* it handled the invocation (the local command must then NOT run).
|
|
3
|
+
* `agents <command>` on a remote host instead of locally. Called once from
|
|
4
|
+
* `index.ts` before commander parses; returns `true` when it handled the
|
|
5
|
+
* invocation (the local command must then NOT run).
|
|
8
6
|
*
|
|
9
7
|
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
8
|
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
@@ -12,17 +10,22 @@
|
|
|
12
10
|
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
11
|
* supervisor outlives a dropped connection.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Commands with their own richer `--host` handling (`run`/`sessions`/`feed`/
|
|
14
|
+
* `computer`/`secrets`/`logs`/…) are listed in {@link OWN_HOST_COMMANDS} and
|
|
15
|
+
* fall through to their local actions. Everything else either routes via this
|
|
16
|
+
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
|
+
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
19
|
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
20
20
|
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
23
23
|
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
24
|
-
* `false` (run locally) when neither flag is present, the command
|
|
25
|
-
*
|
|
24
|
+
* `false` (run locally) when neither flag is present, the command owns its own
|
|
25
|
+
* host handling, the target is this very machine, or placement flags need the
|
|
26
|
+
* local action. Returns `true` after printing a clear error when the flag is
|
|
27
|
+
* present on a command that is neither routable nor self-handling — so the user
|
|
28
|
+
* never sees commander's raw `unknown option '--host'`.
|
|
26
29
|
*
|
|
27
30
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
28
31
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|