@phnx-labs/agents-cli 1.20.31 → 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/CHANGELOG.md +15 -0
- 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/ssh.js +23 -0
- 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 +33 -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/daemon.js +32 -0
- package/dist/lib/devices/pending.d.ts +18 -0
- package/dist/lib/devices/pending.js +103 -0
- package/dist/lib/devices/sync.d.ts +21 -2
- package/dist/lib/devices/sync.js +26 -10
- 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/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/sync-umbrella.js +10 -6
- package/dist/lib/versions.d.ts +13 -4
- package/dist/lib/versions.js +27 -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,98 @@
|
|
|
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
|
+
/** A crabbox machine as reported by `crabbox list --json`. */
|
|
15
|
+
export interface CrabboxBox {
|
|
16
|
+
/** Provider machine name, e.g. `crabbox-blue-hermit-1039689b`. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Provider run state, e.g. `running`. */
|
|
19
|
+
status: string;
|
|
20
|
+
/** Friendly slug used with `--id`, e.g. `blue-hermit`. */
|
|
21
|
+
slug: string;
|
|
22
|
+
/** Lease id, e.g. `cbx_9968746bb15c`. */
|
|
23
|
+
lease: string;
|
|
24
|
+
/** crabbox bootstrap state; `ready` once the box is usable. */
|
|
25
|
+
state: string;
|
|
26
|
+
/** Public IPv4, when the provider exposes one. */
|
|
27
|
+
ip?: string;
|
|
28
|
+
profile?: string;
|
|
29
|
+
class?: string;
|
|
30
|
+
/** True when running + bootstrap-complete. */
|
|
31
|
+
ready: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface CrabboxOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Name of a secrets bundle whose env (e.g. `HCLOUD_TOKEN`) crabbox needs to
|
|
36
|
+
* reach its cloud provider. Resolved via agents-cli's own keychain-backed
|
|
37
|
+
* secrets. When unset, crabbox runs with the ambient environment / its own
|
|
38
|
+
* `crabbox login` credentials.
|
|
39
|
+
*/
|
|
40
|
+
secretsBundle?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Locate the crabbox binary, or throw an actionable error. */
|
|
43
|
+
export declare function findCrabbox(): string;
|
|
44
|
+
/** Build the child env for crabbox, injecting a secrets bundle when configured. */
|
|
45
|
+
export declare function crabboxEnv(opts: CrabboxOptions): NodeJS.ProcessEnv;
|
|
46
|
+
/** All crabbox machines the broker knows about. */
|
|
47
|
+
export declare function crabboxList(opts?: CrabboxOptions): CrabboxBox[];
|
|
48
|
+
/** Find one box by slug, or null. */
|
|
49
|
+
export declare function crabboxFind(slug: string, opts?: CrabboxOptions): CrabboxBox | null;
|
|
50
|
+
export interface WarmupOptions extends CrabboxOptions {
|
|
51
|
+
class?: string;
|
|
52
|
+
profile?: string;
|
|
53
|
+
/** Provision web code-server capability on the box. */
|
|
54
|
+
code?: boolean;
|
|
55
|
+
/** Cloud backend override (crabbox provider id, e.g. hetzner/aws/do). */
|
|
56
|
+
provider?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Lease a box and block until it is ready. Returns the leased box.
|
|
60
|
+
*
|
|
61
|
+
* We diff `crabbox list` before/after so we reliably identify the box this call
|
|
62
|
+
* created even if warmup's stdout format changes — the new lease id is the one
|
|
63
|
+
* that wasn't present before.
|
|
64
|
+
*/
|
|
65
|
+
export declare function crabboxWarmup(opts?: WarmupOptions): CrabboxBox;
|
|
66
|
+
/**
|
|
67
|
+
* Poll until the box reports ready, or throw after timeoutMs.
|
|
68
|
+
* `sleep` is injectable so tests don't wall-clock wait.
|
|
69
|
+
*/
|
|
70
|
+
export declare function crabboxWaitReady(slug: string, opts?: CrabboxOptions & {
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
intervalMs?: number;
|
|
73
|
+
sleep?: (ms: number) => Promise<void>;
|
|
74
|
+
}): Promise<CrabboxBox>;
|
|
75
|
+
export interface CrabboxRunOptions extends CrabboxOptions {
|
|
76
|
+
/** Called with each chunk of combined stdout/stderr as it streams. */
|
|
77
|
+
onData?: (chunk: string) => void;
|
|
78
|
+
/** Force a full remote resync before running. */
|
|
79
|
+
fullResync?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
|
|
83
|
+
* checkout and owns the SSH). Streams combined output; resolves with the remote
|
|
84
|
+
* exit code (or null if crabbox itself failed to dispatch).
|
|
85
|
+
*/
|
|
86
|
+
export declare function crabboxRun(slug: string, remoteCmd: string, opts?: CrabboxRunOptions): Promise<number | null>;
|
|
87
|
+
/**
|
|
88
|
+
* Upload `script` to the box via `crabbox run --script-stdin` and run it.
|
|
89
|
+
*
|
|
90
|
+
* The script body travels over stdin and is written to a file on the box before
|
|
91
|
+
* execution — it never appears in argv / `ps` / shell history, which is why this
|
|
92
|
+
* is the transport for credential provisioning (the token contents live only in
|
|
93
|
+
* the uploaded script, then the file is removed by the script itself).
|
|
94
|
+
* Streams combined output; resolves with the remote exit code (null on dispatch failure).
|
|
95
|
+
*/
|
|
96
|
+
export declare function crabboxRunScript(slug: string, script: string, opts?: CrabboxRunOptions): Promise<number | null>;
|
|
97
|
+
/** Release the lease / delete the box. Best-effort; never throws. */
|
|
98
|
+
export declare function crabboxStop(slug: string, opts?: CrabboxOptions): boolean;
|
|
@@ -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
|
+
}
|
package/dist/lib/daemon.js
CHANGED
|
@@ -290,6 +290,36 @@ export async function runDaemon() {
|
|
|
290
290
|
};
|
|
291
291
|
const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
|
|
292
292
|
const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
|
|
293
|
+
// Device probe: refresh registered devices' reachability and detect newly
|
|
294
|
+
// appeared tailnet nodes, dropping a sentinel per pending device so the
|
|
295
|
+
// menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
|
|
296
|
+
// never auto-registers a newcomer. Soft + overlap-guarded like session sync;
|
|
297
|
+
// a machine without tailscale is a clean no-op. ~every 3 min.
|
|
298
|
+
let probingDevices = false;
|
|
299
|
+
const runDeviceProbe = async () => {
|
|
300
|
+
if (probingDevices)
|
|
301
|
+
return;
|
|
302
|
+
probingDevices = true;
|
|
303
|
+
try {
|
|
304
|
+
const { runDeviceSync } = await import('./devices/sync.js');
|
|
305
|
+
const { reconcilePendingSentinels } = await import('./devices/pending.js');
|
|
306
|
+
const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
|
|
307
|
+
if (dev.ok) {
|
|
308
|
+
reconcilePendingSentinels(dev.pending);
|
|
309
|
+
if (dev.pending.length) {
|
|
310
|
+
log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
log('ERROR', `device probe failed: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
probingDevices = false;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
322
|
+
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
293
323
|
const handleReload = () => {
|
|
294
324
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
295
325
|
scheduler.reloadAll();
|
|
@@ -307,6 +337,8 @@ export async function runDaemon() {
|
|
|
307
337
|
clearInterval(syncInterval);
|
|
308
338
|
clearInterval(healInterval);
|
|
309
339
|
clearTimeout(healKickoff);
|
|
340
|
+
clearInterval(deviceProbeInterval);
|
|
341
|
+
clearTimeout(deviceProbeKickoff);
|
|
310
342
|
removeDaemonPid();
|
|
311
343
|
process.exit(0);
|
|
312
344
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface PendingDevice {
|
|
2
|
+
name: string;
|
|
3
|
+
platform: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Make the sentinel dir exactly match `pending`: create a file per pending
|
|
7
|
+
* device (content = platform), and delete any leftover sentinel whose device is
|
|
8
|
+
* no longer pending (it got registered, ignored, or left the tailnet). Best-
|
|
9
|
+
* effort — a filesystem error here must never crash the daemon, so callers pass
|
|
10
|
+
* this through their existing try/catch.
|
|
11
|
+
*/
|
|
12
|
+
export declare function reconcilePendingSentinels(pending: PendingDevice[]): void;
|
|
13
|
+
/** Remove one device's pending sentinel (after the user registers or ignores it).
|
|
14
|
+
* No-op if it doesn't exist. */
|
|
15
|
+
export declare function clearPendingSentinel(name: string): void;
|
|
16
|
+
/** Read the current pending sentinels (name + platform). Used by tests and any
|
|
17
|
+
* TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
|
|
18
|
+
export declare function readPendingSentinels(): PendingDevice[];
|