@phnx-labs/agents-cli 1.20.32 → 1.20.33
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/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +2 -2
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +7 -12
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +2 -2
- package/dist/commands/view.js +23 -9
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +12 -0
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/bundles.js +29 -20
- package/dist/lib/secrets/index.d.ts +11 -0
- package/dist/lib/secrets/index.js +18 -1
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/versions.d.ts +2 -4
- package/dist/lib/versions.js +7 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- package/dist/lib/agent-spec.js +0 -157
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed wrapper over the external `crabbox` binary (github.com/openclaw/crabbox).
|
|
3
|
+
*
|
|
4
|
+
* crabbox leases ephemeral cloud boxes (Hetzner/DO/EC2/…), syncs the dirty
|
|
5
|
+
* checkout, and runs commands on them. We use it as the transport for
|
|
6
|
+
* `agents run --lease`: warm a box → run the agent on it via `crabbox run` →
|
|
7
|
+
* stop it. crabbox owns the SSH connection, so agents-cli never needs a direct
|
|
8
|
+
* ssh target (unlike the `agents hosts` model).
|
|
9
|
+
*
|
|
10
|
+
* crabbox talks to its cloud provider's API for list/status/warmup/stop, which
|
|
11
|
+
* needs a provider token (e.g. HCLOUD_TOKEN) in the environment. We inject it
|
|
12
|
+
* from a secrets bundle when one is configured (see `crabboxEnv`).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn, spawnSync } from 'child_process';
|
|
15
|
+
import { readAndResolveBundleEnv } from '../secrets/bundles.js';
|
|
16
|
+
/** Locate the crabbox binary, or throw an actionable error. */
|
|
17
|
+
export function findCrabbox() {
|
|
18
|
+
const r = spawnSync('crabbox', ['--help'], { encoding: 'utf-8' });
|
|
19
|
+
if (r.error) {
|
|
20
|
+
throw new Error('crabbox is not installed or not on PATH. Install it and run `crabbox login`, then `crabbox doctor` to verify provider access.');
|
|
21
|
+
}
|
|
22
|
+
return 'crabbox';
|
|
23
|
+
}
|
|
24
|
+
/** Build the child env for crabbox, injecting a secrets bundle when configured. */
|
|
25
|
+
export function crabboxEnv(opts) {
|
|
26
|
+
const bundle = opts.secretsBundle ?? process.env.AGENTS_LEASE_SECRETS_BUNDLE;
|
|
27
|
+
if (!bundle)
|
|
28
|
+
return process.env;
|
|
29
|
+
try {
|
|
30
|
+
// Reuse the same resolver `agents secrets exec` uses so a keychain-backed
|
|
31
|
+
// bundle (e.g. hetzner.com → HCLOUD_TOKEN) reaches crabbox without ever
|
|
32
|
+
// touching disk.
|
|
33
|
+
const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents run --lease (crabbox)' });
|
|
34
|
+
return { ...process.env, ...env };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
throw new Error(`Could not load secrets bundle "${bundle}" for crabbox: ${e.message}. ` +
|
|
38
|
+
`Fix the bundle (agents secrets view ${bundle}) or unset lease.secretsBundle to use crabbox's own login.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function normalizeBox(raw) {
|
|
42
|
+
const labels = (raw.labels ?? {});
|
|
43
|
+
const slug = labels.slug ?? '';
|
|
44
|
+
if (!slug)
|
|
45
|
+
return null;
|
|
46
|
+
const status = String(raw.status ?? '');
|
|
47
|
+
const state = String(labels.state ?? '');
|
|
48
|
+
const publicNet = (raw.public_net ?? {});
|
|
49
|
+
return {
|
|
50
|
+
name: String(raw.name ?? ''),
|
|
51
|
+
status,
|
|
52
|
+
slug,
|
|
53
|
+
lease: labels.lease ?? '',
|
|
54
|
+
state,
|
|
55
|
+
ip: publicNet.ipv4?.ip || undefined,
|
|
56
|
+
profile: labels.profile,
|
|
57
|
+
class: labels.class,
|
|
58
|
+
ready: status === 'running' && state === 'ready',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** All crabbox machines the broker knows about. */
|
|
62
|
+
export function crabboxList(opts = {}) {
|
|
63
|
+
findCrabbox();
|
|
64
|
+
const r = spawnSync('crabbox', ['list', '--json'], { encoding: 'utf-8', env: crabboxEnv(opts) });
|
|
65
|
+
if (r.status !== 0) {
|
|
66
|
+
throw new Error(`crabbox list failed: ${(r.stderr || r.stdout || '').trim() || 'unknown error'}`);
|
|
67
|
+
}
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(r.stdout || '[]');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(parsed))
|
|
76
|
+
return [];
|
|
77
|
+
return parsed.map((b) => normalizeBox(b)).filter((b) => b !== null);
|
|
78
|
+
}
|
|
79
|
+
/** Find one box by slug, or null. */
|
|
80
|
+
export function crabboxFind(slug, opts = {}) {
|
|
81
|
+
return crabboxList(opts).find((b) => b.slug === slug) ?? null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Lease a box and block until it is ready. Returns the leased box.
|
|
85
|
+
*
|
|
86
|
+
* We diff `crabbox list` before/after so we reliably identify the box this call
|
|
87
|
+
* created even if warmup's stdout format changes — the new lease id is the one
|
|
88
|
+
* that wasn't present before.
|
|
89
|
+
*/
|
|
90
|
+
export function crabboxWarmup(opts = {}) {
|
|
91
|
+
findCrabbox();
|
|
92
|
+
const env = crabboxEnv(opts);
|
|
93
|
+
const before = new Set(crabboxList(opts).map((b) => b.lease));
|
|
94
|
+
const args = ['warmup'];
|
|
95
|
+
if (opts.class)
|
|
96
|
+
args.push('--class', opts.class);
|
|
97
|
+
if (opts.profile)
|
|
98
|
+
args.push('--profile', opts.profile);
|
|
99
|
+
if (opts.provider)
|
|
100
|
+
args.push('--provider', opts.provider);
|
|
101
|
+
if (opts.code)
|
|
102
|
+
args.push('--code');
|
|
103
|
+
const r = spawnSync('crabbox', args, { encoding: 'utf-8', env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
104
|
+
if (r.status !== 0) {
|
|
105
|
+
const detail = (r.stderr || r.stdout || '').trim();
|
|
106
|
+
throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
|
|
107
|
+
`Check provider access with \`crabbox doctor\`; a missing cloud token often means \`crabbox login\` or a lease.secretsBundle is needed.`);
|
|
108
|
+
}
|
|
109
|
+
// Prefer the freshly-created box (lease absent from the pre-warmup snapshot).
|
|
110
|
+
const after = crabboxList(opts);
|
|
111
|
+
const fresh = after.filter((b) => !before.has(b.lease));
|
|
112
|
+
if (fresh.length === 1)
|
|
113
|
+
return fresh[0];
|
|
114
|
+
// Fallback: parse the cbx_ lease id crabbox prints and match it.
|
|
115
|
+
const m = (r.stdout || '').match(/cbx_[0-9a-f]+/i);
|
|
116
|
+
if (m) {
|
|
117
|
+
const byLease = after.find((b) => b.lease === m[0]);
|
|
118
|
+
if (byLease)
|
|
119
|
+
return byLease;
|
|
120
|
+
}
|
|
121
|
+
if (fresh.length > 1) {
|
|
122
|
+
// Multiple new boxes (concurrent warmups) — pick the newest ready one.
|
|
123
|
+
const ready = fresh.filter((b) => b.ready);
|
|
124
|
+
if (ready.length)
|
|
125
|
+
return ready[ready.length - 1];
|
|
126
|
+
return fresh[fresh.length - 1];
|
|
127
|
+
}
|
|
128
|
+
throw new Error('crabbox warmup succeeded but the new box could not be located in `crabbox list`.');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Poll until the box reports ready, or throw after timeoutMs.
|
|
132
|
+
* `sleep` is injectable so tests don't wall-clock wait.
|
|
133
|
+
*/
|
|
134
|
+
export async function crabboxWaitReady(slug, opts = {}) {
|
|
135
|
+
const timeoutMs = opts.timeoutMs ?? 180_000;
|
|
136
|
+
const intervalMs = opts.intervalMs ?? 5_000;
|
|
137
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
138
|
+
const deadline = Date.now() + timeoutMs;
|
|
139
|
+
let last = null;
|
|
140
|
+
// First check is immediate (warmup usually returns an already-ready box).
|
|
141
|
+
for (;;) {
|
|
142
|
+
last = crabboxFind(slug, opts);
|
|
143
|
+
if (last?.ready)
|
|
144
|
+
return last;
|
|
145
|
+
if (Date.now() >= deadline)
|
|
146
|
+
break;
|
|
147
|
+
await sleep(intervalMs);
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`crabbox box "${slug}" did not become ready within ${Math.round(timeoutMs / 1000)}s (state: ${last?.state ?? 'gone'}).`);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
|
|
153
|
+
* checkout and owns the SSH). Streams combined output; resolves with the remote
|
|
154
|
+
* exit code (or null if crabbox itself failed to dispatch).
|
|
155
|
+
*/
|
|
156
|
+
export function crabboxRun(slug, remoteCmd, opts = {}) {
|
|
157
|
+
findCrabbox();
|
|
158
|
+
const args = ['run', '--id', slug, '--reclaim'];
|
|
159
|
+
if (opts.fullResync)
|
|
160
|
+
args.push('--full-resync');
|
|
161
|
+
args.push('--', 'bash', '-lc', remoteCmd);
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const proc = spawn('crabbox', args, { env: crabboxEnv(opts), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
164
|
+
const pump = (chunk) => {
|
|
165
|
+
const s = chunk.toString('utf-8');
|
|
166
|
+
if (opts.onData)
|
|
167
|
+
opts.onData(s);
|
|
168
|
+
else
|
|
169
|
+
process.stdout.write(s);
|
|
170
|
+
};
|
|
171
|
+
proc.stdout.on('data', pump);
|
|
172
|
+
proc.stderr.on('data', pump);
|
|
173
|
+
proc.on('error', () => resolve(null));
|
|
174
|
+
proc.on('close', (code) => resolve(code));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Upload `script` to the box via `crabbox run --script-stdin` and run it.
|
|
179
|
+
*
|
|
180
|
+
* The script body travels over stdin and is written to a file on the box before
|
|
181
|
+
* execution — it never appears in argv / `ps` / shell history, which is why this
|
|
182
|
+
* is the transport for credential provisioning (the token contents live only in
|
|
183
|
+
* the uploaded script, then the file is removed by the script itself).
|
|
184
|
+
* Streams combined output; resolves with the remote exit code (null on dispatch failure).
|
|
185
|
+
*/
|
|
186
|
+
export function crabboxRunScript(slug, script, opts = {}) {
|
|
187
|
+
findCrabbox();
|
|
188
|
+
const args = ['run', '--id', slug, '--reclaim'];
|
|
189
|
+
if (opts.fullResync)
|
|
190
|
+
args.push('--full-resync');
|
|
191
|
+
args.push('--script-stdin');
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const proc = spawn('crabbox', args, { env: crabboxEnv(opts), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
194
|
+
const pump = (chunk) => {
|
|
195
|
+
const s = chunk.toString('utf-8');
|
|
196
|
+
if (opts.onData)
|
|
197
|
+
opts.onData(s);
|
|
198
|
+
else
|
|
199
|
+
process.stdout.write(s);
|
|
200
|
+
};
|
|
201
|
+
proc.stdout.on('data', pump);
|
|
202
|
+
proc.stderr.on('data', pump);
|
|
203
|
+
proc.on('error', () => resolve(null));
|
|
204
|
+
proc.on('close', (code) => resolve(code));
|
|
205
|
+
proc.stdin.write(script);
|
|
206
|
+
proc.stdin.end();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Release the lease / delete the box. Best-effort; never throws. */
|
|
210
|
+
export function crabboxStop(slug, opts = {}) {
|
|
211
|
+
try {
|
|
212
|
+
const r = spawnSync('crabbox', ['stop', '--id', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
|
|
213
|
+
return r.status === 0;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents run --lease` orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Lease an ephemeral crabbox → provision the picked runtime(s) + their
|
|
5
|
+
* credentials → run the agent on the box (via `crabbox run`, which owns the
|
|
6
|
+
* SSH) → tear the box down. The whole box-side sequence rides a single
|
|
7
|
+
* `--script-stdin` body so the token contents never touch argv.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentId } from '../types.js';
|
|
10
|
+
import { type CrabboxBox } from './cli.js';
|
|
11
|
+
import { type DetectedRuntime } from './runtimes.js';
|
|
12
|
+
export interface LeaseRunOptions {
|
|
13
|
+
agent: string;
|
|
14
|
+
prompt: string;
|
|
15
|
+
mode?: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
/** Cloud backend crabbox provisions on (hetzner/aws/do/…). */
|
|
18
|
+
backend?: string;
|
|
19
|
+
boxClass?: string;
|
|
20
|
+
profile?: string;
|
|
21
|
+
/** Runtimes to install + authenticate on the box (from the picker). */
|
|
22
|
+
runtimes: AgentId[];
|
|
23
|
+
detected: DetectedRuntime[];
|
|
24
|
+
/** Secrets bundle providing crabbox's provider token. */
|
|
25
|
+
secretsBundle?: string;
|
|
26
|
+
onData?: (s: string) => void;
|
|
27
|
+
/** Keep the box after the run instead of stopping it. */
|
|
28
|
+
keep?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface LeaseRunResult {
|
|
31
|
+
box: CrabboxBox;
|
|
32
|
+
exitCode: number | null;
|
|
33
|
+
toreDown: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build the single bootstrap script run on the box: ensure agents-cli, install
|
|
37
|
+
* the picked runtime CLIs, write their credentials, run the agent, then shred
|
|
38
|
+
* the credential files. Best-effort install steps never abort the run.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildBootstrapScript(opts: LeaseRunOptions): string;
|
|
41
|
+
export declare function leaseAndRun(opts: LeaseRunOptions): Promise<LeaseRunResult>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents run --lease` orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Lease an ephemeral crabbox → provision the picked runtime(s) + their
|
|
5
|
+
* credentials → run the agent on the box (via `crabbox run`, which owns the
|
|
6
|
+
* SSH) → tear the box down. The whole box-side sequence rides a single
|
|
7
|
+
* `--script-stdin` body so the token contents never touch argv.
|
|
8
|
+
*/
|
|
9
|
+
import { crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
|
|
10
|
+
import { buildCredentialScript } from './runtimes.js';
|
|
11
|
+
/** POSIX single-quote for safe embedding in the generated bootstrap script. */
|
|
12
|
+
function q(s) {
|
|
13
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build the single bootstrap script run on the box: ensure agents-cli, install
|
|
17
|
+
* the picked runtime CLIs, write their credentials, run the agent, then shred
|
|
18
|
+
* the credential files. Best-effort install steps never abort the run.
|
|
19
|
+
*/
|
|
20
|
+
export function buildBootstrapScript(opts) {
|
|
21
|
+
const credScript = buildCredentialScript(opts.runtimes, opts.detected);
|
|
22
|
+
const runParts = ['agents', 'run', q(opts.agent), q(opts.prompt), '--quiet'];
|
|
23
|
+
if (opts.mode)
|
|
24
|
+
runParts.push('--mode', q(opts.mode));
|
|
25
|
+
if (opts.model)
|
|
26
|
+
runParts.push('--model', q(opts.model));
|
|
27
|
+
// Credential files to shred after the run (home-level paths written above).
|
|
28
|
+
const shred = opts.runtimes
|
|
29
|
+
.map((id) => {
|
|
30
|
+
const cred = { claude: '.claude.json', codex: '.codex/auth.json', gemini: '.gemini/google_accounts.json', grok: '.grok/auth.json' }[id];
|
|
31
|
+
return cred ? `rm -f "$HOME/${cred}" 2>/dev/null || true` : '';
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join('\n');
|
|
35
|
+
const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
|
|
36
|
+
return [
|
|
37
|
+
'set -uo pipefail',
|
|
38
|
+
'if ! command -v agents >/dev/null 2>&1; then npm install -g @phnx-labs/agents-cli >/dev/null 2>&1 || true; fi',
|
|
39
|
+
installRuntimes,
|
|
40
|
+
credScript,
|
|
41
|
+
`${runParts.join(' ')}`,
|
|
42
|
+
'rc=$?',
|
|
43
|
+
shred,
|
|
44
|
+
'exit $rc',
|
|
45
|
+
]
|
|
46
|
+
.filter((l) => l.length > 0)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
export async function leaseAndRun(opts) {
|
|
50
|
+
const box = crabboxWarmup({
|
|
51
|
+
class: opts.boxClass,
|
|
52
|
+
profile: opts.profile,
|
|
53
|
+
provider: opts.backend,
|
|
54
|
+
secretsBundle: opts.secretsBundle,
|
|
55
|
+
});
|
|
56
|
+
await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
|
|
57
|
+
const script = buildBootstrapScript(opts);
|
|
58
|
+
let exitCode = null;
|
|
59
|
+
let toreDown = false;
|
|
60
|
+
try {
|
|
61
|
+
exitCode = await crabboxRunScript(box.slug, script, {
|
|
62
|
+
secretsBundle: opts.secretsBundle,
|
|
63
|
+
onData: opts.onData,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
// Always attempt teardown (bounds credential lifetime to the run) unless the
|
|
68
|
+
// caller explicitly asked to keep the box.
|
|
69
|
+
if (!opts.keep)
|
|
70
|
+
toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
|
|
71
|
+
}
|
|
72
|
+
return { box, exitCode, toreDown };
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime detection + picker + credential-script builder for `agents run --lease`.
|
|
3
|
+
*
|
|
4
|
+
* The picker asks which coding-agent runtime(s) to provision on a leased box.
|
|
5
|
+
* The default selection is whatever the user is currently signed into locally
|
|
6
|
+
* (via `getAccountInfo`, the same source `agents view` uses). The chosen runtimes
|
|
7
|
+
* drive both what gets installed on the box and which auth token file is copied
|
|
8
|
+
* over — the token contents ride the uploaded `--script-stdin` body, never argv.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: copying a runtime's auth token to an ephemeral cloud box is a
|
|
11
|
+
* credential transfer. It is strictly opt-in (a confirm prompt in the command
|
|
12
|
+
* layer), the token never appears in argv/`ps`, and `--lease` one-shot runs tear
|
|
13
|
+
* the box down afterward so the credential's lifetime is bounded by the run.
|
|
14
|
+
*/
|
|
15
|
+
import type { AgentId } from '../types.js';
|
|
16
|
+
/**
|
|
17
|
+
* Credential file locations per runtime. `localCandidates` are read in order
|
|
18
|
+
* (first existing wins); `remote` is where the box's CLI reads it by default
|
|
19
|
+
* (home-level — no per-version shim). Source of truth for these paths is
|
|
20
|
+
* `getAccountInfo` in src/lib/agents.ts; keep them in sync.
|
|
21
|
+
*/
|
|
22
|
+
interface RuntimeCred {
|
|
23
|
+
id: AgentId;
|
|
24
|
+
label: string;
|
|
25
|
+
localCandidates: string[];
|
|
26
|
+
remote: string;
|
|
27
|
+
}
|
|
28
|
+
export declare const LEASE_RUNTIMES: RuntimeCred[];
|
|
29
|
+
export interface DetectedRuntime {
|
|
30
|
+
id: AgentId;
|
|
31
|
+
label: string;
|
|
32
|
+
email: string | null;
|
|
33
|
+
signedIn: boolean;
|
|
34
|
+
/** Absolute local path of the credential file, if found. */
|
|
35
|
+
credPath: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** Which lease-capable runtimes the user is signed into on this machine. */
|
|
38
|
+
export declare function detectSignedInRuntimes(): Promise<DetectedRuntime[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Interactive checkbox: which runtimes to provision on the box. Defaults to the
|
|
41
|
+
* signed-in ones. Runtimes with no local credential are shown disabled.
|
|
42
|
+
* `prompt` is injected so tests don't require a TTY.
|
|
43
|
+
*/
|
|
44
|
+
export declare function pickRuntimes(detected: DetectedRuntime[], prompt?: (choices: {
|
|
45
|
+
name: string;
|
|
46
|
+
value: AgentId;
|
|
47
|
+
checked: boolean;
|
|
48
|
+
disabled: boolean | string;
|
|
49
|
+
}[]) => Promise<AgentId[]>): Promise<AgentId[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Build a bash snippet that writes each picked runtime's token file to the box's
|
|
52
|
+
* home-level config path (0600), from the token contents read locally. Returns
|
|
53
|
+
* `''` when no runtimes were selected. The snippet is meant to be embedded in
|
|
54
|
+
* the `--script-stdin` body (never argv).
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildCredentialScript(picked: AgentId[], detected: DetectedRuntime[]): string;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime detection + picker + credential-script builder for `agents run --lease`.
|
|
3
|
+
*
|
|
4
|
+
* The picker asks which coding-agent runtime(s) to provision on a leased box.
|
|
5
|
+
* The default selection is whatever the user is currently signed into locally
|
|
6
|
+
* (via `getAccountInfo`, the same source `agents view` uses). The chosen runtimes
|
|
7
|
+
* drive both what gets installed on the box and which auth token file is copied
|
|
8
|
+
* over — the token contents ride the uploaded `--script-stdin` body, never argv.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: copying a runtime's auth token to an ephemeral cloud box is a
|
|
11
|
+
* credential transfer. It is strictly opt-in (a confirm prompt in the command
|
|
12
|
+
* layer), the token never appears in argv/`ps`, and `--lease` one-shot runs tear
|
|
13
|
+
* the box down afterward so the credential's lifetime is bounded by the run.
|
|
14
|
+
*/
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import { getAccountInfo } from '../agents.js';
|
|
19
|
+
export const LEASE_RUNTIMES = [
|
|
20
|
+
{ id: 'claude', label: 'Claude Code', localCandidates: ['.claude/.claude.json', '.claude.json'], remote: '.claude.json' },
|
|
21
|
+
{ id: 'codex', label: 'Codex CLI', localCandidates: ['.codex/auth.json'], remote: '.codex/auth.json' },
|
|
22
|
+
{ id: 'gemini', label: 'Gemini CLI', localCandidates: ['.gemini/google_accounts.json'], remote: '.gemini/google_accounts.json' },
|
|
23
|
+
{ id: 'grok', label: 'Grok CLI', localCandidates: ['.grok/auth.json'], remote: '.grok/auth.json' },
|
|
24
|
+
];
|
|
25
|
+
/** First existing candidate path under the real home, or null. */
|
|
26
|
+
function findLocalCred(cred) {
|
|
27
|
+
const home = process.env.AGENTS_REAL_HOME || os.homedir();
|
|
28
|
+
for (const rel of cred.localCandidates) {
|
|
29
|
+
const p = path.join(home, rel);
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(p))
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* unreadable — skip */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** Which lease-capable runtimes the user is signed into on this machine. */
|
|
41
|
+
export async function detectSignedInRuntimes() {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const cred of LEASE_RUNTIMES) {
|
|
44
|
+
let info;
|
|
45
|
+
try {
|
|
46
|
+
info = await getAccountInfo(cred.id);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
info = null;
|
|
50
|
+
}
|
|
51
|
+
out.push({
|
|
52
|
+
id: cred.id,
|
|
53
|
+
label: cred.label,
|
|
54
|
+
email: info?.email ?? null,
|
|
55
|
+
signedIn: !!info?.signedIn,
|
|
56
|
+
credPath: findLocalCred(cred),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Interactive checkbox: which runtimes to provision on the box. Defaults to the
|
|
63
|
+
* signed-in ones. Runtimes with no local credential are shown disabled.
|
|
64
|
+
* `prompt` is injected so tests don't require a TTY.
|
|
65
|
+
*/
|
|
66
|
+
export async function pickRuntimes(detected, prompt) {
|
|
67
|
+
const choices = detected.map((d) => ({
|
|
68
|
+
name: `${d.label}${d.email ? ` (${d.email})` : d.signedIn ? ' (signed in)' : ''}`,
|
|
69
|
+
value: d.id,
|
|
70
|
+
checked: d.signedIn && !!d.credPath,
|
|
71
|
+
disabled: d.credPath ? false : 'no local credential — sign in first',
|
|
72
|
+
}));
|
|
73
|
+
if (prompt)
|
|
74
|
+
return prompt(choices);
|
|
75
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
76
|
+
return checkbox({ message: 'Provision which runtime(s) on the leased box?', choices });
|
|
77
|
+
}
|
|
78
|
+
// A long random sentinel makes an accidental (or malicious) collision with a
|
|
79
|
+
// token's contents effectively impossible, so the quoted heredoc can never be
|
|
80
|
+
// closed early by the credential body.
|
|
81
|
+
const CRED_EOF = 'AGENTS_LEASE_CRED_EOF_9f3c1a7b5e2d4068';
|
|
82
|
+
/**
|
|
83
|
+
* Build a bash snippet that writes each picked runtime's token file to the box's
|
|
84
|
+
* home-level config path (0600), from the token contents read locally. Returns
|
|
85
|
+
* `''` when no runtimes were selected. The snippet is meant to be embedded in
|
|
86
|
+
* the `--script-stdin` body (never argv).
|
|
87
|
+
*/
|
|
88
|
+
export function buildCredentialScript(picked, detected) {
|
|
89
|
+
const byId = new Map(detected.map((d) => [d.id, d]));
|
|
90
|
+
const parts = [];
|
|
91
|
+
for (const id of picked) {
|
|
92
|
+
const d = byId.get(id);
|
|
93
|
+
const cred = LEASE_RUNTIMES.find((c) => c.id === id);
|
|
94
|
+
if (!d?.credPath || !cred)
|
|
95
|
+
continue;
|
|
96
|
+
let contents;
|
|
97
|
+
try {
|
|
98
|
+
contents = fs.readFileSync(d.credPath, 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const dir = path.posix.dirname(cred.remote);
|
|
104
|
+
const mkdir = dir && dir !== '.' ? `mkdir -p "$HOME/${dir}"\n` : '';
|
|
105
|
+
parts.push(`${mkdir}cat > "$HOME/${cred.remote}" <<'${CRED_EOF}'\n${contents}${contents.endsWith('\n') ? '' : '\n'}${CRED_EOF}\n` +
|
|
106
|
+
`chmod 600 "$HOME/${cred.remote}"`);
|
|
107
|
+
}
|
|
108
|
+
return parts.join('\n');
|
|
109
|
+
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dispatch a headless
|
|
2
|
+
* Dispatch a headless `agents …` command onto a host over SSH.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* log and its exit code to a sibling `.exit` file, so progress survives a
|
|
6
|
-
* connection (followed via offset-tail in progress.ts). This is the
|
|
7
|
-
* the
|
|
4
|
+
* The command is launched detached (`nohup … &`) writing combined output to a
|
|
5
|
+
* remote log and its exit code to a sibling `.exit` file, so progress survives a
|
|
6
|
+
* dropped connection (followed via offset-tail in progress.ts). This is the
|
|
7
|
+
* offload win: the process/thread/file fan-out happens on the host, not the
|
|
8
|
+
* laptop. `agents run` uses it; `agents teams start --watch --host` reuses the
|
|
9
|
+
* same core so a remote team supervisor keeps running after you disconnect.
|
|
8
10
|
*/
|
|
9
11
|
import type { Host } from './types.js';
|
|
10
12
|
import { type HostTask } from './tasks.js';
|
|
13
|
+
export interface DispatchResult {
|
|
14
|
+
task: HostTask;
|
|
15
|
+
/** Exit code when followed; undefined when detached (--no-follow). */
|
|
16
|
+
exitCode?: number;
|
|
17
|
+
}
|
|
11
18
|
export interface DispatchOptions {
|
|
12
19
|
agent: string;
|
|
13
20
|
prompt: string;
|
|
@@ -18,9 +25,19 @@ export interface DispatchOptions {
|
|
|
18
25
|
follow?: boolean;
|
|
19
26
|
timeoutMs?: number;
|
|
20
27
|
}
|
|
21
|
-
|
|
22
|
-
task: HostTask;
|
|
23
|
-
/** Exit code when followed; undefined when detached (--no-follow). */
|
|
24
|
-
exitCode?: number;
|
|
25
|
-
}
|
|
28
|
+
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
26
29
|
export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
|
|
30
|
+
export interface CommandDispatchOptions {
|
|
31
|
+
/** `agents …` args (command name first), already stripped of routing flags. */
|
|
32
|
+
forwardedArgs: string[];
|
|
33
|
+
remoteCwd?: string;
|
|
34
|
+
follow?: boolean;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Dispatch an arbitrary long-running `agents <command>` onto a host detached —
|
|
39
|
+
* used for `teams start --watch --host`, whose supervisor must outlive the SSH
|
|
40
|
+
* connection. Reachability is assumed (the caller has already resolved the host);
|
|
41
|
+
* a launch failure surfaces the remote stderr.
|
|
42
|
+
*/
|
|
43
|
+
export declare function dispatchAgentsCommand(host: Host, opts: CommandDispatchOptions): Promise<DispatchResult>;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dispatch a headless
|
|
2
|
+
* Dispatch a headless `agents …` command onto a host over SSH.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* log and its exit code to a sibling `.exit` file, so progress survives a
|
|
6
|
-
* connection (followed via offset-tail in progress.ts). This is the
|
|
7
|
-
* the
|
|
4
|
+
* The command is launched detached (`nohup … &`) writing combined output to a
|
|
5
|
+
* remote log and its exit code to a sibling `.exit` file, so progress survives a
|
|
6
|
+
* dropped connection (followed via offset-tail in progress.ts). This is the
|
|
7
|
+
* offload win: the process/thread/file fan-out happens on the host, not the
|
|
8
|
+
* laptop. `agents run` uses it; `agents teams start --watch --host` reuses the
|
|
9
|
+
* same core so a remote team supervisor keeps running after you disconnect.
|
|
8
10
|
*/
|
|
9
11
|
import { randomUUID } from 'crypto';
|
|
10
12
|
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
@@ -16,25 +18,22 @@ import { followHostTask } from './progress.js';
|
|
|
16
18
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
17
19
|
// injection-safe to interpolate unquoted into remote commands.
|
|
18
20
|
const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
23
|
+
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
24
|
+
* so the nohup/exit-file/offset-tail machinery lives in exactly one place.
|
|
25
|
+
*/
|
|
26
|
+
async function launchDetached(host, target, opts) {
|
|
24
27
|
const id = randomUUID().slice(0, 8);
|
|
25
28
|
const remoteLog = `${REMOTE_DIR}/${id}.log`;
|
|
26
29
|
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
27
30
|
// Inner command run under a login shell so PATH resolves `agents`.
|
|
28
|
-
const
|
|
29
|
-
if (opts.mode)
|
|
30
|
-
runParts.push('--mode', shellQuote(opts.mode));
|
|
31
|
-
if (opts.model)
|
|
32
|
-
runParts.push('--model', shellQuote(opts.model));
|
|
31
|
+
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
33
32
|
const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
|
|
34
|
-
const inner = `${cwd}${
|
|
33
|
+
const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
35
34
|
// Outer: ensure dir, launch detached under bash -lc, print the PID.
|
|
36
35
|
const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
|
|
37
|
-
const res = sshExec(target, launch, { timeoutMs: 30000 });
|
|
36
|
+
const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
|
|
38
37
|
if (res.code !== 0) {
|
|
39
38
|
throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
|
|
40
39
|
}
|
|
@@ -43,8 +42,8 @@ export async function dispatchToHost(host, opts) {
|
|
|
43
42
|
id,
|
|
44
43
|
host: host.name,
|
|
45
44
|
target,
|
|
46
|
-
agent: opts.
|
|
47
|
-
prompt: opts.
|
|
45
|
+
agent: opts.agentLabel,
|
|
46
|
+
prompt: opts.promptLabel,
|
|
48
47
|
pid: Number.isFinite(pid) ? pid : undefined,
|
|
49
48
|
remoteLog,
|
|
50
49
|
remoteExit,
|
|
@@ -69,3 +68,40 @@ export async function dispatchToHost(host, opts) {
|
|
|
69
68
|
});
|
|
70
69
|
return { task: finished ?? task, exitCode };
|
|
71
70
|
}
|
|
71
|
+
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
72
|
+
export async function dispatchToHost(host, opts) {
|
|
73
|
+
const target = sshTargetFor(host);
|
|
74
|
+
const { warnings } = ensureHostReady(host, { agent: opts.agent });
|
|
75
|
+
for (const w of warnings)
|
|
76
|
+
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
77
|
+
const forwardedArgs = ['run', opts.agent, opts.prompt, '--quiet'];
|
|
78
|
+
if (opts.mode)
|
|
79
|
+
forwardedArgs.push('--mode', opts.mode);
|
|
80
|
+
if (opts.model)
|
|
81
|
+
forwardedArgs.push('--model', opts.model);
|
|
82
|
+
return launchDetached(host, target, {
|
|
83
|
+
forwardedArgs,
|
|
84
|
+
remoteCwd: opts.remoteCwd,
|
|
85
|
+
follow: opts.follow,
|
|
86
|
+
timeoutMs: opts.timeoutMs,
|
|
87
|
+
agentLabel: opts.agent,
|
|
88
|
+
promptLabel: opts.prompt,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Dispatch an arbitrary long-running `agents <command>` onto a host detached —
|
|
93
|
+
* used for `teams start --watch --host`, whose supervisor must outlive the SSH
|
|
94
|
+
* connection. Reachability is assumed (the caller has already resolved the host);
|
|
95
|
+
* a launch failure surfaces the remote stderr.
|
|
96
|
+
*/
|
|
97
|
+
export async function dispatchAgentsCommand(host, opts) {
|
|
98
|
+
const target = sshTargetFor(host);
|
|
99
|
+
return launchDetached(host, target, {
|
|
100
|
+
forwardedArgs: opts.forwardedArgs,
|
|
101
|
+
remoteCwd: opts.remoteCwd,
|
|
102
|
+
follow: opts.follow,
|
|
103
|
+
timeoutMs: opts.timeoutMs,
|
|
104
|
+
agentLabel: opts.forwardedArgs[0] ?? 'agents',
|
|
105
|
+
promptLabel: opts.forwardedArgs.join(' '),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `--host` option registrar. Every command that can run on a remote host
|
|
3
|
+
* declares the flag through here, so its spelling, help text, and companions
|
|
4
|
+
* (`--remote-cwd`, `--no-tty`, `--any`) stay identical everywhere and show up in
|
|
5
|
+
* each command's `--help`.
|
|
6
|
+
*
|
|
7
|
+
* The flags are consumed centrally by `maybeRunOnHost` (passthrough.ts) *before*
|
|
8
|
+
* commander parses, so for a real remote run the local action never sees them.
|
|
9
|
+
* Registering them here still matters: it documents the flag and keeps the local
|
|
10
|
+
* fall-through (e.g. `--host <this-machine>`) from erroring on an unknown option.
|
|
11
|
+
*/
|
|
12
|
+
import type { Command } from 'commander';
|
|
13
|
+
/** Attach the standard `--host` flag family to a command and return it (chainable). */
|
|
14
|
+
export declare function addHostOption(cmd: Command): Command;
|