@phnx-labs/agents-cli 1.20.64 → 1.20.66
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 +51 -3
- package/README.md +156 -3
- 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.d.ts +48 -0
- package/dist/commands/exec.js +159 -49
- package/dist/commands/feed.js +25 -11
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/mcp.js +55 -5
- package/dist/commands/monitors.d.ts +12 -0
- package/dist/commands/monitors.js +748 -0
- package/dist/commands/output.js +2 -2
- package/dist/commands/plugins.js +28 -7
- package/dist/commands/routines.js +23 -2
- 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-browser.d.ts +82 -0
- package/dist/commands/sessions-browser.js +320 -0
- package/dist/commands/sessions-export.js +8 -3
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +157 -10
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +150 -0
- package/dist/commands/ssh.js +54 -3
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +26 -0
- 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 +46 -0
- package/dist/lib/agents.js +121 -3
- 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/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/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.js +5 -1
- package/dist/lib/crabbox/runtimes.js +11 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +62 -19
- package/dist/lib/devices/connect.d.ts +18 -1
- package/dist/lib/devices/connect.js +10 -2
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/known-hosts.d.ts +62 -0
- package/dist/lib/devices/known-hosts.js +137 -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 +66 -20
- 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 +79 -4
- 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/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 +205 -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 +45 -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/picker.d.ts +53 -0
- package/dist/lib/picker.js +214 -1
- package/dist/lib/platform/exec.d.ts +16 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/plugins.d.ts +31 -1
- package/dist/lib/plugins.js +175 -15
- 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 +16 -0
- package/dist/lib/routines.js +19 -0
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +102 -9
- package/dist/lib/secrets/agent.d.ts +83 -10
- package/dist/lib/secrets/agent.js +237 -70
- package/dist/lib/secrets/bundles.d.ts +26 -0
- package/dist/lib/secrets/bundles.js +59 -8
- package/dist/lib/secrets/filestore.d.ts +9 -0
- package/dist/lib/secrets/filestore.js +21 -8
- package/dist/lib/secrets/index.d.ts +7 -0
- package/dist/lib/secrets/index.js +26 -6
- 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 +14 -0
- package/dist/lib/session/bundle.js +12 -1
- 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/config.js +8 -2
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/share/capture.d.ts +29 -0
- package/dist/lib/share/capture.js +140 -0
- package/dist/lib/share/config.d.ts +35 -0
- package/dist/lib/share/config.js +100 -0
- package/dist/lib/share/og.d.ts +25 -0
- package/dist/lib/share/og.js +84 -0
- package/dist/lib/share/provision.d.ts +10 -0
- package/dist/lib/share/provision.js +91 -0
- package/dist/lib/share/publish.d.ts +57 -0
- package/dist/lib/share/publish.js +145 -0
- package/dist/lib/share/worker-template.d.ts +2 -0
- package/dist/lib/share/worker-template.js +82 -0
- package/dist/lib/shims.d.ts +13 -0
- package/dist/lib/shims.js +42 -2
- package/dist/lib/ssh-exec.d.ts +24 -0
- package/dist/lib/ssh-exec.js +15 -3
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +86 -7
- package/dist/lib/startup/command-registry.d.ts +3 -0
- package/dist/lib/startup/command-registry.js +6 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- 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 +36 -1
- package/dist/lib/usage.js +7 -5
- 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' : ''}…`));
|
package/dist/commands/plugins.js
CHANGED
|
@@ -573,6 +573,7 @@ Examples:
|
|
|
573
573
|
pluginsCmd
|
|
574
574
|
.command('update [name]')
|
|
575
575
|
.description('Re-pull a plugin from its original source and re-sync to all versions')
|
|
576
|
+
.option('--allow-exec-surfaces', 'Consent to an update that introduces new executable surfaces (hooks/, bin/, scripts/, .mcp.json, settings.json, permissions/)')
|
|
576
577
|
.addHelpText('after', `
|
|
577
578
|
Examples:
|
|
578
579
|
# Update a specific plugin
|
|
@@ -580,8 +581,11 @@ Examples:
|
|
|
580
581
|
|
|
581
582
|
# Update all plugins
|
|
582
583
|
agents plugins update
|
|
584
|
+
|
|
585
|
+
# Trust an update that adds new executable surfaces
|
|
586
|
+
agents plugins update rush-toolkit --allow-exec-surfaces
|
|
583
587
|
`)
|
|
584
|
-
.action(async (nameArg) => {
|
|
588
|
+
.action(async (nameArg, options) => {
|
|
585
589
|
const plugins = nameArg ? [getPlugin(nameArg)].filter(Boolean) : discoverPlugins();
|
|
586
590
|
if (nameArg && plugins.length === 0) {
|
|
587
591
|
console.log(chalk.red(`Plugin '${nameArg}' not found`));
|
|
@@ -591,24 +595,41 @@ Examples:
|
|
|
591
595
|
console.log(chalk.gray('No plugins installed.'));
|
|
592
596
|
return;
|
|
593
597
|
}
|
|
598
|
+
const allowExec = options.allowExecSurfaces === true;
|
|
594
599
|
for (const plugin of plugins) {
|
|
595
600
|
process.stdout.write(`Updating ${plugin.name}... `);
|
|
596
|
-
const result = await updatePlugin(plugin.name);
|
|
601
|
+
const result = await updatePlugin(plugin.name, { allowExecSurfaces: allowExec });
|
|
597
602
|
if (!result.success) {
|
|
598
|
-
|
|
603
|
+
if (result.blockedByExecSurfaces) {
|
|
604
|
+
// Security (RUSH-1757): the update introduced new executable surfaces.
|
|
605
|
+
// Refuse without renewed consent; the last-good content stays in place.
|
|
606
|
+
console.log(chalk.yellow('skipped'));
|
|
607
|
+
console.log(chalk.yellow(` Update introduces new executable surfaces: ${(result.newExecSurfaces || []).join(', ')}`));
|
|
608
|
+
console.log(chalk.gray(' Kept the currently-installed revision. Re-run with --allow-exec-surfaces if you trust the source.'));
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
console.log(chalk.red(`failed — ${result.error || 'unknown error'}`));
|
|
612
|
+
}
|
|
599
613
|
continue;
|
|
600
614
|
}
|
|
601
615
|
console.log(chalk.green('done'));
|
|
602
|
-
//
|
|
616
|
+
// Reload the plugin so the re-sync reads the freshly-applied revision.
|
|
617
|
+
const updated = getPlugin(plugin.name) ?? plugin;
|
|
618
|
+
// Re-sync to all supported installed versions. When the applied revision
|
|
619
|
+
// carries executable surfaces, only enable them if the user consented on
|
|
620
|
+
// this update (--allow-exec-surfaces); otherwise the benign content syncs
|
|
621
|
+
// but stays disabled, matching the install-time trust gate.
|
|
603
622
|
for (const agentId of capableAgents('plugins')) {
|
|
604
|
-
if (!pluginSupportsAgent(
|
|
623
|
+
if (!pluginSupportsAgent(updated, agentId))
|
|
605
624
|
continue;
|
|
606
625
|
const versions = listInstalledVersions(agentId);
|
|
607
626
|
const defaultVer = getGlobalDefault(agentId);
|
|
608
627
|
const targetVersions = defaultVer ? [defaultVer] : versions.slice(-1);
|
|
609
628
|
for (const version of targetVersions) {
|
|
610
|
-
const
|
|
611
|
-
|
|
629
|
+
const didSync = allowExec
|
|
630
|
+
? syncPluginToVersion(updated, agentId, getVersionHomePath(agentId, version), { allowExecSurfaces: true, version }).success
|
|
631
|
+
: syncResourcesToVersion(agentId, version, { plugins: [updated.name] }).plugins.length > 0;
|
|
632
|
+
if (didSync) {
|
|
612
633
|
console.log(chalk.gray(` Re-synced to ${agentLabel(agentId)}@${version}`));
|
|
613
634
|
}
|
|
614
635
|
}
|
|
@@ -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 ""
|
|
@@ -289,6 +289,7 @@ export function registerRoutinesCommands(program) {
|
|
|
289
289
|
trigger: job.trigger ?? null,
|
|
290
290
|
timezone: job.timezone ?? null,
|
|
291
291
|
devices: job.devices ?? [],
|
|
292
|
+
host: job.host ?? null,
|
|
292
293
|
runsHere: jobRunsOnThisDevice(job),
|
|
293
294
|
enabled: job.enabled,
|
|
294
295
|
overdue: overdueSet.has(job.name),
|
|
@@ -340,7 +341,10 @@ export function registerRoutinesCommands(program) {
|
|
|
340
341
|
// chalk adds escape codes; pad the raw word and let chalk wrap it.
|
|
341
342
|
const enabledWord = job.enabled ? 'yes' : 'no';
|
|
342
343
|
const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
|
|
343
|
-
|
|
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(' ');
|
|
344
348
|
const deviceWord = deviceFull.length === 0
|
|
345
349
|
? 'all'
|
|
346
350
|
: deviceFull.length > DEVICE_W
|
|
@@ -384,6 +388,8 @@ export function registerRoutinesCommands(program) {
|
|
|
384
388
|
.option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
|
|
385
389
|
.option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
|
|
386
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)')
|
|
387
393
|
.option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
|
|
388
394
|
.option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
|
|
389
395
|
.option('--repo <owner/name>', 'GitHub repo filter for --on github:<event>')
|
|
@@ -448,6 +454,13 @@ export function registerRoutinesCommands(program) {
|
|
|
448
454
|
if (options.devices !== undefined) {
|
|
449
455
|
devices = await parseAndValidateDevices(options.devices);
|
|
450
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
|
+
}
|
|
451
464
|
const config = {
|
|
452
465
|
name: nameOrPath,
|
|
453
466
|
...(schedule ? { schedule } : {}),
|
|
@@ -462,6 +475,8 @@ export function registerRoutinesCommands(program) {
|
|
|
462
475
|
prompt: options.prompt ?? '',
|
|
463
476
|
timezone: options.timezone,
|
|
464
477
|
...(devices ? { devices } : {}),
|
|
478
|
+
...(options.runOn ? { host: options.runOn } : {}),
|
|
479
|
+
...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
|
|
465
480
|
...(runOnce ? { runOnce: true } : {}),
|
|
466
481
|
...(options.endAt ? { endAt: options.endAt } : {}),
|
|
467
482
|
...(options.resume ? { resume: options.resume } : {}),
|
|
@@ -520,6 +535,12 @@ export function registerRoutinesCommands(program) {
|
|
|
520
535
|
enabled: true,
|
|
521
536
|
...parsed,
|
|
522
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
|
+
}
|
|
523
544
|
writeJob(config);
|
|
524
545
|
console.log(chalk.green(`Job '${name}' added`));
|
|
525
546
|
ensureSchedulerRunning();
|
|
@@ -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]')
|
package/dist/commands/serve.js
CHANGED
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { startServeServer, DEFAULT_SERVE_PORT, SERVE_HOST } from '../lib/serve/server.js';
|
|
3
|
+
import { startControlServer } from '../lib/serve/control.js';
|
|
4
|
+
import { ensureControlToken } from '../lib/serve/token.js';
|
|
3
5
|
export function registerServeCommand(program) {
|
|
4
6
|
program
|
|
5
7
|
.command('serve')
|
|
6
8
|
.description('Read-only local web companion: team diffs, routines, and cloud status (binds 127.0.0.1 only).')
|
|
7
9
|
.option('-p, --port <n>', `Port to bind on ${SERVE_HOST}`, String(DEFAULT_SERVE_PORT))
|
|
8
10
|
.option('--interval <ms>', 'SSE refresh cadence in milliseconds', '3000')
|
|
11
|
+
.option('--control', 'Authenticated control mode (RUSH-1731): adds bearer-gated POST /api/run and /api/session/:id/message so the iOS cockpit can dispatch + steer. Enables binding beyond loopback — pair a device with `agents devices pair-ios`.', false)
|
|
12
|
+
.option('--bind <addr>', 'Address to bind in --control mode (e.g. your tailnet IP, or 0.0.0.0). Ignored without --control; read-only serve is always loopback.', SERVE_HOST)
|
|
9
13
|
.action(async (opts) => {
|
|
10
14
|
const port = parseInt(opts.port ?? '', 10) || DEFAULT_SERVE_PORT;
|
|
11
15
|
const intervalMs = parseInt(opts.interval ?? '', 10) || 3000;
|
|
12
16
|
try {
|
|
17
|
+
if (opts.control) {
|
|
18
|
+
const bind = opts.bind || SERVE_HOST;
|
|
19
|
+
const minted = ensureControlToken('default');
|
|
20
|
+
const { server, port: bound } = await startControlServer(port, bind, {
|
|
21
|
+
cwd: process.cwd(),
|
|
22
|
+
intervalMs,
|
|
23
|
+
});
|
|
24
|
+
const url = `http://${bind}:${bound}`;
|
|
25
|
+
console.log(`${chalk.green('agents serve --control')} ${chalk.dim('→')} ${chalk.cyan(url)}`);
|
|
26
|
+
if (minted.created) {
|
|
27
|
+
console.log(chalk.yellow('New control token (shown once — store it in the cockpit now):'));
|
|
28
|
+
console.log(` ${chalk.bold(minted.token)} ${chalk.dim(`(id ${minted.id})`)}`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
console.log(chalk.dim('Using existing control token(s). Issue another with `agents devices pair-ios`.'));
|
|
32
|
+
}
|
|
33
|
+
if (bind !== SERVE_HOST) {
|
|
34
|
+
console.log(chalk.dim('bearer-gated · bind beyond loopback — keep this on the tailnet, never public Funnel · Ctrl-C to stop'));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
console.log(chalk.dim('bearer-gated · loopback (pass --bind <tailnet-ip> to reach it from the phone) · Ctrl-C to stop'));
|
|
38
|
+
}
|
|
39
|
+
const shutdown = () => server.close(() => process.exit(0));
|
|
40
|
+
process.on('SIGINT', shutdown);
|
|
41
|
+
process.on('SIGTERM', shutdown);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
13
44
|
const { server, port: bound } = await startServeServer(port, {
|
|
14
45
|
cwd: process.cwd(),
|
|
15
46
|
intervalMs,
|