@phnx-labs/agents-cli 1.20.63 → 1.20.65
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 +56 -3
- package/README.md +46 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.d.ts +12 -0
- package/dist/commands/apply.js +274 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/cloud.js +32 -2
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/exec.js +149 -77
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +52 -19
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/mcp.js +55 -5
- package/dist/commands/monitors.d.ts +12 -0
- package/dist/commands/monitors.js +740 -0
- package/dist/commands/output.js +2 -2
- package/dist/commands/routines.js +43 -16
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +215 -64
- package/dist/commands/serve.js +31 -0
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +284 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +40 -6
- package/dist/commands/ssh.js +143 -5
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +27 -1
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +35 -14
- package/dist/lib/agents.d.ts +36 -0
- package/dist/lib/agents.js +80 -20
- package/dist/lib/auto-dispatch-provider.js +7 -2
- package/dist/lib/auto-dispatch.d.ts +3 -0
- package/dist/lib/auto-dispatch.js +3 -0
- package/dist/lib/browser/chrome.js +2 -2
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/cloud/antigravity.js +2 -2
- package/dist/lib/cloud/host.d.ts +59 -0
- package/dist/lib/cloud/host.js +224 -0
- package/dist/lib/cloud/registry.js +4 -0
- package/dist/lib/cloud/types.d.ts +6 -4
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +162 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +35 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +68 -20
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/devices/registry.d.ts +15 -0
- package/dist/lib/devices/registry.js +9 -0
- package/dist/lib/exec.d.ts +19 -2
- package/dist/lib/exec.js +41 -13
- package/dist/lib/fleet/apply.d.ts +63 -0
- package/dist/lib/fleet/apply.js +214 -0
- package/dist/lib/fleet/auth-sync.d.ts +67 -0
- package/dist/lib/fleet/auth-sync.js +142 -0
- package/dist/lib/fleet/manifest.d.ts +29 -0
- package/dist/lib/fleet/manifest.js +127 -0
- package/dist/lib/fleet/types.d.ts +129 -0
- package/dist/lib/fleet/types.js +13 -0
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +34 -2
- package/dist/lib/hosts/dispatch.d.ts +29 -8
- package/dist/lib/hosts/dispatch.js +46 -18
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/providers/devices.d.ts +27 -0
- package/dist/lib/hosts/providers/devices.js +98 -0
- package/dist/lib/hosts/registry.d.ts +10 -16
- package/dist/lib/hosts/registry.js +17 -50
- package/dist/lib/hosts/remote-cmd.d.ts +23 -0
- package/dist/lib/hosts/remote-cmd.js +71 -0
- package/dist/lib/hosts/run-target.d.ts +84 -0
- package/dist/lib/hosts/run-target.js +99 -0
- package/dist/lib/hosts/types.d.ts +23 -5
- package/dist/lib/hosts/types.js +22 -4
- package/dist/lib/linear-autoclose.d.ts +30 -0
- package/dist/lib/linear-autoclose.js +22 -0
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/mcp.d.ts +27 -1
- package/dist/lib/mcp.js +126 -12
- package/dist/lib/monitors/config.d.ts +161 -0
- package/dist/lib/monitors/config.js +372 -0
- package/dist/lib/monitors/dispatch.d.ts +28 -0
- package/dist/lib/monitors/dispatch.js +91 -0
- package/dist/lib/monitors/engine.d.ts +61 -0
- package/dist/lib/monitors/engine.js +201 -0
- package/dist/lib/monitors/sources/command.d.ts +11 -0
- package/dist/lib/monitors/sources/command.js +31 -0
- package/dist/lib/monitors/sources/device.d.ts +13 -0
- package/dist/lib/monitors/sources/device.js +35 -0
- package/dist/lib/monitors/sources/file.d.ts +14 -0
- package/dist/lib/monitors/sources/file.js +57 -0
- package/dist/lib/monitors/sources/http.d.ts +10 -0
- package/dist/lib/monitors/sources/http.js +34 -0
- package/dist/lib/monitors/sources/index.d.ts +14 -0
- package/dist/lib/monitors/sources/index.js +31 -0
- package/dist/lib/monitors/sources/poll.d.ts +9 -0
- package/dist/lib/monitors/sources/poll.js +9 -0
- package/dist/lib/monitors/sources/types.d.ts +18 -0
- package/dist/lib/monitors/sources/types.js +9 -0
- package/dist/lib/monitors/sources/webhook.d.ts +23 -0
- package/dist/lib/monitors/sources/webhook.js +47 -0
- package/dist/lib/monitors/sources/ws.d.ts +14 -0
- package/dist/lib/monitors/sources/ws.js +45 -0
- package/dist/lib/monitors/state.d.ts +69 -0
- package/dist/lib/monitors/state.js +144 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/platform/exec.d.ts +16 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/plugins.js +101 -2
- package/dist/lib/redact.d.ts +14 -1
- package/dist/lib/redact.js +47 -1
- package/dist/lib/remote-agents-json.js +7 -1
- package/dist/lib/rotate.d.ts +6 -3
- package/dist/lib/rotate.js +0 -1
- package/dist/lib/routines.d.ts +37 -2
- package/dist/lib/routines.js +54 -12
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +348 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/secrets/agent.d.ts +48 -10
- package/dist/lib/secrets/agent.js +123 -15
- package/dist/lib/secrets/bundles.d.ts +26 -0
- package/dist/lib/secrets/bundles.js +59 -8
- package/dist/lib/secrets/mcp.js +4 -2
- package/dist/lib/secrets/remote.d.ts +17 -0
- package/dist/lib/secrets/remote.js +40 -0
- package/dist/lib/self-update.d.ts +20 -0
- package/dist/lib/self-update.js +54 -1
- package/dist/lib/serve/control.d.ts +95 -0
- package/dist/lib/serve/control.js +260 -0
- package/dist/lib/serve/server.d.ts +35 -1
- package/dist/lib/serve/server.js +106 -76
- package/dist/lib/serve/stream.d.ts +43 -0
- package/dist/lib/serve/stream.js +116 -0
- package/dist/lib/serve/token.d.ts +35 -0
- package/dist/lib/serve/token.js +85 -0
- package/dist/lib/session/bundle.d.ts +164 -0
- package/dist/lib/session/bundle.js +200 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/remote-list.js +5 -1
- package/dist/lib/session/state.d.ts +7 -25
- package/dist/lib/session/state.js +16 -6
- package/dist/lib/session/sync/agents.d.ts +54 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.js +8 -2
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/sync.d.ts +23 -2
- package/dist/lib/session/sync/sync.js +177 -74
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +99 -8
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +9 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/tmux/session.d.ts +7 -0
- package/dist/lib/tmux/session.js +3 -1
- package/dist/lib/triggers/webhook.d.ts +18 -0
- package/dist/lib/triggers/webhook.js +105 -0
- package/dist/lib/types.d.ts +35 -1
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +163 -21
- package/dist/lib/versions.js +14 -11
- package/dist/lib/workflows.d.ts +20 -0
- package/dist/lib/workflows.js +24 -0
- package/package.json +2 -1
package/dist/commands/output.js
CHANGED
|
@@ -10,7 +10,7 @@ import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
|
|
|
10
10
|
import { formatDuration } from '../lib/session/render.js';
|
|
11
11
|
import { terminalWidth, truncateToWidth, padToWidth } from '../lib/session/width.js';
|
|
12
12
|
import { collectGitOutput } from '../lib/output/git-output.js';
|
|
13
|
-
import { loadDevices } from '../lib/devices/registry.js';
|
|
13
|
+
import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
|
|
14
14
|
import { machineId } from '../lib/session/sync/config.js';
|
|
15
15
|
const execFileAsync = promisify(execFile);
|
|
16
16
|
export function registerOutputCommand(program) {
|
|
@@ -154,7 +154,7 @@ async function outputAction(options) {
|
|
|
154
154
|
const self = machineId();
|
|
155
155
|
const registry = await loadDevices();
|
|
156
156
|
const remotes = Object.values(registry)
|
|
157
|
-
.filter(d => d.tailscale?.online && d.name !== self)
|
|
157
|
+
.filter(d => d.tailscale?.online && d.name !== self && !isControlDevice(d))
|
|
158
158
|
.map(d => d.name);
|
|
159
159
|
if (!options.json)
|
|
160
160
|
console.error(chalk.gray(`Folding in ${remotes.length} online device${remotes.length !== 1 ? 's' : ''}…`));
|
|
@@ -23,7 +23,7 @@ import { detectOverdueJobs } from '../lib/overdue.js';
|
|
|
23
23
|
import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
|
|
24
24
|
import { setHelpSections } from '../lib/help.js';
|
|
25
25
|
import { loadDevices } from '../lib/devices/registry.js';
|
|
26
|
-
import { normalizeHost } from '../lib/machine-id.js';
|
|
26
|
+
import { machineId, normalizeHost } from '../lib/machine-id.js';
|
|
27
27
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
28
28
|
/**
|
|
29
29
|
* Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
|
|
@@ -149,7 +149,7 @@ async function pickJob(message, filter, alternatives = [], cwd) {
|
|
|
149
149
|
message,
|
|
150
150
|
choices: jobs.map((job) => ({
|
|
151
151
|
value: job.name,
|
|
152
|
-
name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
|
|
152
|
+
name: `${job.name} ${chalk.gray(`(${job.command ? 'command' : job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
|
|
153
153
|
})),
|
|
154
154
|
});
|
|
155
155
|
}
|
|
@@ -282,12 +282,14 @@ export function registerRoutinesCommands(program) {
|
|
|
282
282
|
name: job.name,
|
|
283
283
|
agent: job.agent ?? null,
|
|
284
284
|
workflow: job.workflow ?? null,
|
|
285
|
+
command: job.command ?? null,
|
|
285
286
|
repo: job.repo ?? null,
|
|
286
287
|
schedule: job.schedule ?? null,
|
|
287
288
|
scheduleHuman: fireConditionLabel(job),
|
|
288
289
|
trigger: job.trigger ?? null,
|
|
289
290
|
timezone: job.timezone ?? null,
|
|
290
291
|
devices: job.devices ?? [],
|
|
292
|
+
host: job.host ?? null,
|
|
291
293
|
runsHere: jobRunsOnThisDevice(job),
|
|
292
294
|
enabled: job.enabled,
|
|
293
295
|
overdue: overdueSet.has(job.name),
|
|
@@ -339,7 +341,10 @@ export function registerRoutinesCommands(program) {
|
|
|
339
341
|
// chalk adds escape codes; pad the raw word and let chalk wrap it.
|
|
340
342
|
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
341
343
|
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
342
|
-
|
|
344
|
+
// Placement (`→host`) rides in the Devices cell: eligibility →execution.
|
|
345
|
+
const deviceFull = [job.devices?.join(',') ?? '', job.host ? `→${job.host}` : '']
|
|
346
|
+
.filter(Boolean)
|
|
347
|
+
.join(' ');
|
|
343
348
|
const deviceWord = deviceFull.length === 0
|
|
344
349
|
? 'all'
|
|
345
350
|
: deviceFull.length > DEVICE_W
|
|
@@ -356,9 +361,11 @@ export function registerRoutinesCommands(program) {
|
|
|
356
361
|
: lastStatus === 'timeout' ? chalk.yellow
|
|
357
362
|
: chalk.gray;
|
|
358
363
|
const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
|
|
359
|
-
const agentLabelPadded = job.
|
|
360
|
-
? chalk.magenta(
|
|
361
|
-
:
|
|
364
|
+
const agentLabelPadded = job.command
|
|
365
|
+
? chalk.magenta('command'.padEnd(10))
|
|
366
|
+
: job.workflow
|
|
367
|
+
? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
|
|
368
|
+
: (job.agent || '').padEnd(10);
|
|
362
369
|
console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
|
|
363
370
|
}
|
|
364
371
|
if (overdueSet.size > 0) {
|
|
@@ -374,12 +381,15 @@ export function registerRoutinesCommands(program) {
|
|
|
374
381
|
.option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
|
|
375
382
|
.option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
|
|
376
383
|
.option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
|
|
384
|
+
.option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
|
|
377
385
|
.option('-p, --prompt <prompt>', 'Task instruction for the agent')
|
|
378
386
|
.option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier, the default), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", 'auto')
|
|
379
387
|
.option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
|
|
380
388
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
381
389
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
|
382
390
|
.option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
|
|
391
|
+
.option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
|
|
392
|
+
.option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
|
|
383
393
|
.option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
|
|
384
394
|
.option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
|
|
385
395
|
.option('--repo <owner/name>', 'GitHub repo filter for --on github:<event>')
|
|
@@ -392,7 +402,7 @@ export function registerRoutinesCommands(program) {
|
|
|
392
402
|
.option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
|
|
393
403
|
.action(async (nameOrPath, options) => {
|
|
394
404
|
// Check if inline mode (has flags) or file mode
|
|
395
|
-
const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at || options.on;
|
|
405
|
+
const hasInlineFlags = options.schedule || options.agent || options.workflow || options.command || options.prompt || options.at || options.on;
|
|
396
406
|
if (hasInlineFlags) {
|
|
397
407
|
// Inline mode: create job from flags
|
|
398
408
|
if (!nameOrPath) {
|
|
@@ -400,9 +410,9 @@ export function registerRoutinesCommands(program) {
|
|
|
400
410
|
console.log(chalk.gray('Usage: agents routines add <name> --schedule "..." --agent <agent> --prompt "..."'));
|
|
401
411
|
process.exit(1);
|
|
402
412
|
}
|
|
403
|
-
// Validate mutually exclusive --agent / --workflow
|
|
404
|
-
if (options.agent
|
|
405
|
-
console.log(chalk.red('--agent and --
|
|
413
|
+
// Validate mutually exclusive --agent / --workflow / --command
|
|
414
|
+
if ([options.agent, options.workflow, options.command].filter(Boolean).length > 1) {
|
|
415
|
+
console.log(chalk.red('--agent, --workflow, and --command are mutually exclusive; specify exactly one'));
|
|
406
416
|
process.exit(1);
|
|
407
417
|
}
|
|
408
418
|
let schedule = options.schedule;
|
|
@@ -430,11 +440,12 @@ export function registerRoutinesCommands(program) {
|
|
|
430
440
|
console.log(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
|
|
431
441
|
process.exit(1);
|
|
432
442
|
}
|
|
433
|
-
if (!options.agent && !options.workflow) {
|
|
434
|
-
console.log(chalk.red('An agent or
|
|
443
|
+
if (!options.agent && !options.workflow && !options.command) {
|
|
444
|
+
console.log(chalk.red('An agent, workflow, or command is required (use --agent, --workflow, or --command)'));
|
|
435
445
|
process.exit(1);
|
|
436
446
|
}
|
|
437
|
-
|
|
447
|
+
// Command routines run a plain shell and take no prompt; agent/workflow routines require one.
|
|
448
|
+
if (!options.command && !options.prompt) {
|
|
438
449
|
console.log(chalk.red('Prompt is required (use --prompt)'));
|
|
439
450
|
process.exit(1);
|
|
440
451
|
}
|
|
@@ -443,19 +454,29 @@ export function registerRoutinesCommands(program) {
|
|
|
443
454
|
if (options.devices !== undefined) {
|
|
444
455
|
devices = await parseAndValidateDevices(options.devices);
|
|
445
456
|
}
|
|
457
|
+
// --run-on without a --devices pin would fire on EVERY daemon in the
|
|
458
|
+
// fleet, each dispatching to the same host — duplicate runs. Pin to
|
|
459
|
+
// this machine unless the user chose an explicit eligibility set.
|
|
460
|
+
if (options.runOn && !devices) {
|
|
461
|
+
devices = [machineId()];
|
|
462
|
+
console.log(chalk.gray(`--run-on set with no --devices: pinned firing to this machine (${devices[0]}).`));
|
|
463
|
+
}
|
|
446
464
|
const config = {
|
|
447
465
|
name: nameOrPath,
|
|
448
466
|
...(schedule ? { schedule } : {}),
|
|
449
467
|
...(trigger ? { trigger } : {}),
|
|
450
|
-
agent: options.agent,
|
|
468
|
+
...(options.agent ? { agent: options.agent } : {}),
|
|
451
469
|
...(options.workflow ? { workflow: options.workflow } : {}),
|
|
470
|
+
...(options.command ? { command: options.command } : {}),
|
|
452
471
|
mode: options.mode,
|
|
453
472
|
effort: options.effort,
|
|
454
473
|
timeout: options.timeout,
|
|
455
474
|
enabled: !options.disabled,
|
|
456
|
-
prompt: options.prompt,
|
|
475
|
+
prompt: options.prompt ?? '',
|
|
457
476
|
timezone: options.timezone,
|
|
458
477
|
...(devices ? { devices } : {}),
|
|
478
|
+
...(options.runOn ? { host: options.runOn } : {}),
|
|
479
|
+
...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
|
|
459
480
|
...(runOnce ? { runOnce: true } : {}),
|
|
460
481
|
...(options.endAt ? { endAt: options.endAt } : {}),
|
|
461
482
|
...(options.resume ? { resume: options.resume } : {}),
|
|
@@ -514,6 +535,12 @@ export function registerRoutinesCommands(program) {
|
|
|
514
535
|
enabled: true,
|
|
515
536
|
...parsed,
|
|
516
537
|
};
|
|
538
|
+
// Same duplicate-fire guard as the --run-on flag: a host-placed routine
|
|
539
|
+
// with no eligibility pin would fire from every daemon in the fleet.
|
|
540
|
+
if (config.host && (!config.devices || config.devices.length === 0)) {
|
|
541
|
+
config.devices = [machineId()];
|
|
542
|
+
console.log(chalk.gray(`host: set with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
|
|
543
|
+
}
|
|
517
544
|
writeJob(config);
|
|
518
545
|
console.log(chalk.green(`Job '${name}' added`));
|
|
519
546
|
ensureSchedulerRunning();
|
|
@@ -664,7 +691,7 @@ export function registerRoutinesCommands(program) {
|
|
|
664
691
|
console.log(chalk.gray(` ${eligibility.suggestion}`));
|
|
665
692
|
process.exit(1);
|
|
666
693
|
}
|
|
667
|
-
const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
|
|
694
|
+
const runLabel = job.command ? 'command' : job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
|
|
668
695
|
console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
|
|
669
696
|
const spinner = ora('Executing...').start();
|
|
670
697
|
try {
|
|
@@ -66,11 +66,27 @@ export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secret
|
|
|
66
66
|
* parse, so multi-line values are rejected rather than silently corrupted.
|
|
67
67
|
*/
|
|
68
68
|
export declare function bundleEnvToDotenv(env: Record<string, string>): string;
|
|
69
|
+
/**
|
|
70
|
+
* Encrypt a resolved env map to an offline bundle file using AES-256-GCM
|
|
71
|
+
* (the same EncFile envelope as the per-item file store). Inner plaintext is
|
|
72
|
+
* JSON so multi-line values round-trip losslessly. Written with mode 0600;
|
|
73
|
+
* the passphrase must be supplied explicitly — never auto-provisioned.
|
|
74
|
+
*/
|
|
75
|
+
export declare function exportBundleToFile(env: Record<string, string>, filePath: string, passphrase: string): void;
|
|
76
|
+
/**
|
|
77
|
+
* Decrypt and parse an offline bundle file produced by exportBundleToFile.
|
|
78
|
+
* Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
|
|
79
|
+
*/
|
|
80
|
+
export declare function importBundleFromFile(filePath: string, passphrase: string): Record<string, string>;
|
|
69
81
|
/** The POLICY column for `secrets list`: the prompt policy, plus a concise
|
|
70
82
|
* state hint. `daily` shows `held Nh` when the secrets-agent is currently
|
|
71
83
|
* caching the bundle; `always` and `never` show whether they prompt. `held`
|
|
72
84
|
* maps bundle name → expiry epoch-ms (from agentStatus()). */
|
|
73
85
|
export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
|
|
86
|
+
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
87
|
+
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
88
|
+
* hours up to 2 days, whole days beyond. Pure — unit-tested. */
|
|
89
|
+
export declare function formatHoldWindow(ms: number): string;
|
|
74
90
|
/** Register the `agents secrets` command tree. */
|
|
75
91
|
export declare function registerSecretsCommands(program: Command): void;
|
|
76
92
|
/** Validate a prompt-policy value, throwing a clear message on a bad one (the
|
package/dist/commands/secrets.js
CHANGED
|
@@ -16,10 +16,13 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
|
|
|
16
16
|
import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveSshTarget, } from '../lib/secrets/remote.js';
|
|
17
17
|
import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
|
|
18
18
|
import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
|
|
19
|
-
import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
19
|
+
import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
20
|
+
import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
|
|
20
21
|
import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
21
22
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
22
|
-
import {
|
|
23
|
+
import { secretsHoldMs, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
|
|
24
|
+
import { getCliVersionFresh } from '../lib/version.js';
|
|
25
|
+
import { readMeta } from '../lib/state.js';
|
|
23
26
|
import { parseDuration } from '../lib/hooks/cache.js';
|
|
24
27
|
import { emit } from '../lib/events.js';
|
|
25
28
|
import { registerCommandGroups, setHelpSections } from '../lib/help.js';
|
|
@@ -298,6 +301,52 @@ export function bundleEnvToDotenv(env) {
|
|
|
298
301
|
}
|
|
299
302
|
return lines.join('\n') + '\n';
|
|
300
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Encrypt a resolved env map to an offline bundle file using AES-256-GCM
|
|
306
|
+
* (the same EncFile envelope as the per-item file store). Inner plaintext is
|
|
307
|
+
* JSON so multi-line values round-trip losslessly. Written with mode 0600;
|
|
308
|
+
* the passphrase must be supplied explicitly — never auto-provisioned.
|
|
309
|
+
*/
|
|
310
|
+
export function exportBundleToFile(env, filePath, passphrase) {
|
|
311
|
+
const enc = encryptForFallback(JSON.stringify(env), passphrase);
|
|
312
|
+
fs.writeFileSync(filePath, JSON.stringify(enc), { mode: 0o600 });
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Decrypt and parse an offline bundle file produced by exportBundleToFile.
|
|
316
|
+
* Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
|
|
317
|
+
*/
|
|
318
|
+
export function importBundleFromFile(filePath, passphrase) {
|
|
319
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
320
|
+
let enc;
|
|
321
|
+
try {
|
|
322
|
+
enc = JSON.parse(raw);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw new Error(`Encrypted bundle file ${filePath} is corrupt (not valid JSON).`);
|
|
326
|
+
}
|
|
327
|
+
let plaintext;
|
|
328
|
+
try {
|
|
329
|
+
plaintext = decryptForFallback(enc, passphrase);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
throw new Error(`Failed to decrypt bundle file ${filePath}. Wrong passphrase or tampered file.`);
|
|
333
|
+
}
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(plaintext);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
throw new Error(`Decrypted bundle file ${filePath} has invalid content (expected JSON).`);
|
|
340
|
+
}
|
|
341
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
342
|
+
throw new Error(`Bundle file ${filePath} has unexpected structure.`);
|
|
343
|
+
}
|
|
344
|
+
const result = {};
|
|
345
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
346
|
+
result[k] = typeof v === 'string' ? v : String(v);
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
301
350
|
/**
|
|
302
351
|
* Browse `agents secrets <args>` on one or more remote hosts over SSH and print
|
|
303
352
|
* each host's stdout verbatim (lossless — no parsing). With >1 host the output
|
|
@@ -400,6 +449,22 @@ export function renderPolicyCol(b, held) {
|
|
|
400
449
|
const exp = held?.get(b.name);
|
|
401
450
|
return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
|
|
402
451
|
}
|
|
452
|
+
/** Human-readable hold window for `secrets status`. Sub-hour values render in
|
|
453
|
+
* minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
|
|
454
|
+
* hours up to 2 days, whole days beyond. Pure — unit-tested. */
|
|
455
|
+
export function formatHoldWindow(ms) {
|
|
456
|
+
if (ms < 3_600_000) { // under an hour → minutes (never a confusing "0 hours")
|
|
457
|
+
const mins = Math.max(1, Math.round(ms / 60_000));
|
|
458
|
+
if (mins < 60)
|
|
459
|
+
return `${mins} minute${mins === 1 ? '' : 's'}`;
|
|
460
|
+
// 59.99m rounds to 60 — call it 1 hour rather than "60 minutes".
|
|
461
|
+
}
|
|
462
|
+
const hrs = Math.round(ms / 3_600_000);
|
|
463
|
+
if (hrs < 48)
|
|
464
|
+
return `${hrs} hour${hrs === 1 ? '' : 's'}`;
|
|
465
|
+
const days = Math.round(hrs / 24);
|
|
466
|
+
return `${days} day${days === 1 ? '' : 's'}`;
|
|
467
|
+
}
|
|
403
468
|
/** Below this width the fixed date columns no longer fit; `list` uses cards. */
|
|
404
469
|
const SECRETS_WIDE = 96;
|
|
405
470
|
/** Format a single bundle as a table row for the `secrets list` output. */
|
|
@@ -568,6 +633,51 @@ function countExpiringSoon(meta) {
|
|
|
568
633
|
}
|
|
569
634
|
return n;
|
|
570
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* Resolve an existing import target bundle (inheriting its backend) or create a
|
|
638
|
+
* new one with the requested backend. Refuses to silently downgrade a
|
|
639
|
+
* keychain-backed bundle to `file` — shared by every `import` source so the
|
|
640
|
+
* guard can't drift between them.
|
|
641
|
+
*/
|
|
642
|
+
function resolveImportBundle(name, backendOpt) {
|
|
643
|
+
const requestedBackend = parseBackendOpt(backendOpt);
|
|
644
|
+
if (bundleExists(name)) {
|
|
645
|
+
const bundle = readBundle(name);
|
|
646
|
+
if (requestedBackend === 'file' && bundle.backend !== 'file') {
|
|
647
|
+
throw new Error(`Bundle '${name}' already exists with a keychain backend; ` +
|
|
648
|
+
`--backend file cannot change it. Delete it first to recreate as file-backed.`);
|
|
649
|
+
}
|
|
650
|
+
return bundle;
|
|
651
|
+
}
|
|
652
|
+
return { name, backend: requestedBackend === 'file' ? 'file' : undefined, vars: {} };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Apply KEY=VALUE entries into a bundle (keychain item or plaintext literal),
|
|
656
|
+
* honoring `--force`, then persist. Returns the added/skipped tally. Shared by
|
|
657
|
+
* the .env, --from-file, and --from-ssh import paths.
|
|
658
|
+
*/
|
|
659
|
+
function applyEnvToBundle(bundle, env, opts) {
|
|
660
|
+
const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
|
|
661
|
+
let added = 0;
|
|
662
|
+
let skipped = 0;
|
|
663
|
+
for (const [key, value] of Object.entries(env)) {
|
|
664
|
+
if (!opts.force && key in bundle.vars) {
|
|
665
|
+
skipped++;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (opts.allPlaintext) {
|
|
669
|
+
bundle.vars[key] = { value };
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
const item = secretsKeychainItem(bundle.name, key);
|
|
673
|
+
store.set(item, value);
|
|
674
|
+
bundle.vars[key] = keychainRef(key);
|
|
675
|
+
}
|
|
676
|
+
added++;
|
|
677
|
+
}
|
|
678
|
+
writeBundle(bundle);
|
|
679
|
+
return { added, skipped };
|
|
680
|
+
}
|
|
571
681
|
/** Register the `agents secrets` command tree. */
|
|
572
682
|
export function registerSecretsCommands(program) {
|
|
573
683
|
const cmd = program
|
|
@@ -854,7 +964,10 @@ export function registerSecretsCommands(program) {
|
|
|
854
964
|
console.error(chalk.red(`Secrets bundle '${item}' not found.`));
|
|
855
965
|
process.exit(1);
|
|
856
966
|
}
|
|
857
|
-
|
|
967
|
+
// `secrets get` is the scriptable automation primitive ($(agents secrets
|
|
968
|
+
// get bundle KEY)); when embedded in a headless routine/CI script it must
|
|
969
|
+
// not pop an unwatched Touch ID prompt. Interactive use still prompts.
|
|
970
|
+
const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], agentOnly: isHeadlessSecretsContext() });
|
|
858
971
|
if (!(key in env)) {
|
|
859
972
|
console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
|
|
860
973
|
process.exit(1);
|
|
@@ -1301,8 +1414,46 @@ Examples:
|
|
|
1301
1414
|
.option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
|
|
1302
1415
|
.option('--force', 'Overwrite an existing key in the bundle')
|
|
1303
1416
|
.option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
|
|
1417
|
+
.option('--from-file <path>', 'Import from an AES-256-GCM encrypted offline bundle file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of export --to-file)')
|
|
1418
|
+
.option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --host)')
|
|
1419
|
+
.option('--host <peer>', 'SSH peer to pull from when using --from-ssh (host alias or user@host)')
|
|
1304
1420
|
.action(async (bundleName, opts) => {
|
|
1305
1421
|
try {
|
|
1422
|
+
// A single import can name only one source. --from-file / --from-ssh are
|
|
1423
|
+
// early-return paths, so guard them against each other and the --from /
|
|
1424
|
+
// --from-1password pair (which parseImportSource guards on its own).
|
|
1425
|
+
const namedFileOrSsh = [
|
|
1426
|
+
opts.fromFile ? '--from-file' : null,
|
|
1427
|
+
opts.fromSsh ? '--from-ssh' : null,
|
|
1428
|
+
].filter(Boolean);
|
|
1429
|
+
if (namedFileOrSsh.length > 1 || (namedFileOrSsh.length > 0 && (opts.from || opts.from1password))) {
|
|
1430
|
+
throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
|
|
1431
|
+
}
|
|
1432
|
+
if (opts.fromFile) {
|
|
1433
|
+
const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
|
|
1434
|
+
if (!passphrase) {
|
|
1435
|
+
throw new Error('--from-file needs AGENTS_SECRETS_PASSPHRASE set to decrypt the bundle file.');
|
|
1436
|
+
}
|
|
1437
|
+
const env = importBundleFromFile(opts.fromFile, passphrase);
|
|
1438
|
+
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
1439
|
+
const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
|
|
1440
|
+
const { added, skipped } = applyEnvToBundle(bundle, env, opts);
|
|
1441
|
+
console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (opts.fromSsh) {
|
|
1445
|
+
if (!opts.host) {
|
|
1446
|
+
throw new Error('--from-ssh requires --host <peer>.');
|
|
1447
|
+
}
|
|
1448
|
+
assertValidSshTarget(opts.host);
|
|
1449
|
+
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
1450
|
+
const target = await resolveSshTarget(opts.host);
|
|
1451
|
+
const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.host });
|
|
1452
|
+
const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
|
|
1453
|
+
const { added, skipped } = applyEnvToBundle(bundle, env, opts);
|
|
1454
|
+
console.log(chalk.green(`Imported ${added} key(s) from ${opts.host}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1306
1457
|
const source = parseImportSource(opts);
|
|
1307
1458
|
if (opts.purge && source.kind !== 'icloud') {
|
|
1308
1459
|
throw new Error('--purge only applies to --from icloud.');
|
|
@@ -1321,49 +1472,20 @@ Examples:
|
|
|
1321
1472
|
return;
|
|
1322
1473
|
}
|
|
1323
1474
|
const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
// file` works (
|
|
1327
|
-
// drives on the remote).
|
|
1328
|
-
|
|
1329
|
-
if (bundleExists(resolvedBundleName)) {
|
|
1330
|
-
bundle = readBundle(resolvedBundleName);
|
|
1331
|
-
if (requestedBackend === 'file' && bundle.backend !== 'file') {
|
|
1332
|
-
throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
|
|
1333
|
-
`--backend file cannot change it. Delete it first to recreate as file-backed.`);
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
else {
|
|
1337
|
-
bundle = {
|
|
1338
|
-
name: resolvedBundleName,
|
|
1339
|
-
backend: requestedBackend === 'file' ? 'file' : undefined,
|
|
1340
|
-
vars: {},
|
|
1341
|
-
};
|
|
1342
|
-
}
|
|
1343
|
-
const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
|
|
1344
|
-
let added = 0;
|
|
1345
|
-
let skipped = 0;
|
|
1475
|
+
// resolveImportBundle inherits an existing bundle's backend (and refuses
|
|
1476
|
+
// to downgrade keychain -> file) or creates it with the requested backend
|
|
1477
|
+
// so a single `import --backend file` works (what `export --host ...
|
|
1478
|
+
// --remote-backend file` drives on the remote).
|
|
1479
|
+
const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
|
|
1346
1480
|
if (source.kind === '1password') {
|
|
1347
1481
|
assertOpAvailable();
|
|
1348
1482
|
const vault = await resolveVault(source.vault);
|
|
1349
1483
|
const items = listItems(vault);
|
|
1350
1484
|
const { secrets, skipped: opSkipped } = extractSecrets(items, vault);
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
}
|
|
1356
|
-
if (opts.allPlaintext) {
|
|
1357
|
-
bundle.vars[envKey] = { value };
|
|
1358
|
-
}
|
|
1359
|
-
else {
|
|
1360
|
-
const item = secretsKeychainItem(resolvedBundleName, envKey);
|
|
1361
|
-
store.set(item, value);
|
|
1362
|
-
bundle.vars[envKey] = keychainRef(envKey);
|
|
1363
|
-
}
|
|
1364
|
-
added++;
|
|
1365
|
-
}
|
|
1366
|
-
writeBundle(bundle);
|
|
1485
|
+
const env = {};
|
|
1486
|
+
for (const { envKey, value } of secrets)
|
|
1487
|
+
env[envKey] = value;
|
|
1488
|
+
const { added, skipped } = applyEnvToBundle(bundle, env, opts);
|
|
1367
1489
|
if (opSkipped.length) {
|
|
1368
1490
|
console.log(chalk.yellow(`Skipped ${opSkipped.length} item(s) with no importable fields.`));
|
|
1369
1491
|
}
|
|
@@ -1372,22 +1494,7 @@ Examples:
|
|
|
1372
1494
|
else {
|
|
1373
1495
|
const raw = readImportDotenv(source.path);
|
|
1374
1496
|
const pairs = parseDotenv(raw);
|
|
1375
|
-
|
|
1376
|
-
if (!opts.force && key in bundle.vars) {
|
|
1377
|
-
skipped++;
|
|
1378
|
-
continue;
|
|
1379
|
-
}
|
|
1380
|
-
if (opts.allPlaintext) {
|
|
1381
|
-
bundle.vars[key] = { value };
|
|
1382
|
-
}
|
|
1383
|
-
else {
|
|
1384
|
-
const item = secretsKeychainItem(resolvedBundleName, key);
|
|
1385
|
-
store.set(item, value);
|
|
1386
|
-
bundle.vars[key] = keychainRef(key);
|
|
1387
|
-
}
|
|
1388
|
-
added++;
|
|
1389
|
-
}
|
|
1390
|
-
writeBundle(bundle);
|
|
1497
|
+
const { added, skipped } = applyEnvToBundle(bundle, pairs, opts);
|
|
1391
1498
|
console.log(chalk.green(`Imported ${added} key(s)${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
|
|
1392
1499
|
}
|
|
1393
1500
|
}
|
|
@@ -1408,10 +1515,22 @@ Examples:
|
|
|
1408
1515
|
.option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
|
|
1409
1516
|
.option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
|
|
1410
1517
|
.option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
|
|
1518
|
+
.option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
|
|
1411
1519
|
.action(async (bundleName, opts) => {
|
|
1412
1520
|
try {
|
|
1413
1521
|
const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
|
|
1414
1522
|
const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
|
|
1523
|
+
if (opts.toFile) {
|
|
1524
|
+
const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
|
|
1525
|
+
if (!passphrase) {
|
|
1526
|
+
throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
|
|
1527
|
+
'Set it for this command, then supply the same value when importing.');
|
|
1528
|
+
}
|
|
1529
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', agentOnly: isHeadlessSecretsContext() });
|
|
1530
|
+
exportBundleToFile(env, opts.toFile, passphrase);
|
|
1531
|
+
console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1415
1534
|
// The presence of --host selects SSH push: --host is the destination
|
|
1416
1535
|
// and carries the mode (no separate --to-ssh needed — it would be
|
|
1417
1536
|
// strictly redundant since SSH always requires at least one host).
|
|
@@ -1434,7 +1553,7 @@ Examples:
|
|
|
1434
1553
|
'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
|
|
1435
1554
|
}
|
|
1436
1555
|
}
|
|
1437
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export
|
|
1556
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, agentOnly: isHeadlessSecretsContext() });
|
|
1438
1557
|
const dotenv = bundleEnvToDotenv(env);
|
|
1439
1558
|
const keyCount = Object.keys(env).length;
|
|
1440
1559
|
// Drive the remote's own `agents secrets import --from -` so the values
|
|
@@ -1499,7 +1618,7 @@ Examples:
|
|
|
1499
1618
|
if (opts.to1password) {
|
|
1500
1619
|
assertOpAvailable();
|
|
1501
1620
|
const vault = await resolveVault(opts.vault);
|
|
1502
|
-
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}
|
|
1621
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, agentOnly: isHeadlessSecretsContext() });
|
|
1503
1622
|
let created = 0;
|
|
1504
1623
|
let overwritten = 0;
|
|
1505
1624
|
let skipped = 0;
|
|
@@ -1537,7 +1656,15 @@ Examples:
|
|
|
1537
1656
|
console.error(chalk.red('export prints secrets in the clear and requires --plaintext (works for TTY and pipes alike).'));
|
|
1538
1657
|
process.exit(1);
|
|
1539
1658
|
}
|
|
1540
|
-
|
|
1659
|
+
// `agents secrets export --plaintext` is what release/CI scripts eval.
|
|
1660
|
+
// When it runs detached (both stdio non-TTY) or under a headless agent,
|
|
1661
|
+
// resolve broker-only so it can never pop a Touch ID sheet on the
|
|
1662
|
+
// interactive user's screen. An interactive `eval "$(...)"` keeps its
|
|
1663
|
+
// terminal stdin, so it is not headless and still prompts.
|
|
1664
|
+
const { env } = readAndResolveBundleEnv(resolvedBundleName, {
|
|
1665
|
+
caller: `export to shell`,
|
|
1666
|
+
agentOnly: isHeadlessSecretsContext(),
|
|
1667
|
+
});
|
|
1541
1668
|
if (opts.format === 'json') {
|
|
1542
1669
|
// Lossless, machine-readable form consumed by `remoteResolveEnv` over
|
|
1543
1670
|
// SSH. Single object of KEY -> value; values verbatim (newlines, quotes).
|
|
@@ -1592,6 +1719,7 @@ Examples:
|
|
|
1592
1719
|
caller: `command ${cmd}`,
|
|
1593
1720
|
keys: keysSubset,
|
|
1594
1721
|
allowExpired: execOpts.allowExpired,
|
|
1722
|
+
agentOnly: isHeadlessSecretsContext(),
|
|
1595
1723
|
}).env;
|
|
1596
1724
|
}
|
|
1597
1725
|
const { spawn } = await import('child_process');
|
|
@@ -1760,7 +1888,7 @@ Examples:
|
|
|
1760
1888
|
console.error(chalk.red('Specify one or more bundle names, or --all.'));
|
|
1761
1889
|
process.exit(1);
|
|
1762
1890
|
}
|
|
1763
|
-
let ttlMs =
|
|
1891
|
+
let ttlMs = secretsHoldMs(); // default hold, capped by secrets.agent.holdMs
|
|
1764
1892
|
if (opts.ttl) {
|
|
1765
1893
|
const secs = parseDuration(opts.ttl);
|
|
1766
1894
|
if (!secs) {
|
|
@@ -1830,21 +1958,44 @@ Examples:
|
|
|
1830
1958
|
console.log(chalk.gray('secrets-agent is macOS-only.'));
|
|
1831
1959
|
return;
|
|
1832
1960
|
}
|
|
1833
|
-
const
|
|
1961
|
+
const ping = await agentPing();
|
|
1962
|
+
const brokerUp = ping.reachable;
|
|
1834
1963
|
console.log(chalk.gray('broker: ') +
|
|
1835
1964
|
(brokerUp
|
|
1836
1965
|
? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
|
|
1837
1966
|
: chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
|
|
1967
|
+
// Diagnostic: version skew is the top reason a `daily` bundle keeps
|
|
1968
|
+
// re-prompting — a broker on an older build gets torn down when the CLI
|
|
1969
|
+
// version changes (e.g. `agents-cli-update`), wiping every held bundle.
|
|
1970
|
+
const onDisk = getCliVersionFresh();
|
|
1971
|
+
if (brokerUp && ping.cliVersion && ping.cliVersion !== onDisk) {
|
|
1972
|
+
console.log(chalk.yellow(` warning: broker is running an older build (${ping.cliVersion} vs ${onDisk} on disk). ` +
|
|
1973
|
+
`A version change can wipe held bundles — reads re-warm on the next access.`));
|
|
1974
|
+
}
|
|
1975
|
+
// Surface the hold window so "why did it prompt again" is answerable.
|
|
1976
|
+
const holdStr = formatHoldWindow(secretsHoldMs());
|
|
1977
|
+
// Only claim "(secrets.agent.holdMs)" when the config is actually honored —
|
|
1978
|
+
// an invalid value (0/NaN/negative) falls back to the default via
|
|
1979
|
+
// clampHoldMs, so it must read "(default)", not misattribute to config.
|
|
1980
|
+
const configured = (() => { try {
|
|
1981
|
+
const v = readMeta().secrets?.agent?.holdMs;
|
|
1982
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
1983
|
+
}
|
|
1984
|
+
catch {
|
|
1985
|
+
return false;
|
|
1986
|
+
} })();
|
|
1987
|
+
console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
|
|
1838
1988
|
const entries = await agentStatus();
|
|
1839
1989
|
if (entries.length === 0) {
|
|
1840
|
-
console.log(chalk.gray('No bundles
|
|
1841
|
-
console.log(chalk.gray('
|
|
1990
|
+
console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
|
|
1991
|
+
console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
|
|
1842
1992
|
return;
|
|
1843
1993
|
}
|
|
1844
1994
|
console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
|
|
1845
1995
|
for (const e of entries) {
|
|
1846
1996
|
console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
|
|
1847
1997
|
}
|
|
1998
|
+
console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
|
|
1848
1999
|
});
|
|
1849
2000
|
cmd
|
|
1850
2001
|
.command('policy <bundle> [policy]')
|