@phnx-labs/agents-cli 1.20.64 → 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 +39 -3
- package/README.md +37 -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 +94 -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 +740 -0
- package/dist/commands/output.js +2 -2
- 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-export.js +8 -3
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +36 -6
- package/dist/commands/ssh.js +45 -2
- 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 +34 -14
- package/dist/lib/agents.d.ts +18 -0
- package/dist/lib/agents.js +53 -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/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -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/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/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 +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 +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 +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/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +86 -7
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -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 +26 -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/lib/runner.js
CHANGED
|
@@ -23,6 +23,8 @@ import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
|
|
|
23
23
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
24
24
|
import { createTimer, maybeRotate, redactPrompt } from './events.js';
|
|
25
25
|
import { normalizeMode, buildExecEnv, detectRateLimit, } from './exec.js';
|
|
26
|
+
import { loadTask as loadHostTask } from './hosts/tasks.js';
|
|
27
|
+
import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
|
|
26
28
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
27
29
|
import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
28
30
|
import { getConfiguredRunStrategy, resolveRunVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
@@ -448,6 +450,12 @@ export async function executeJob(config, deps) {
|
|
|
448
450
|
if (eligibility) {
|
|
449
451
|
throw new Error(eligibility.message);
|
|
450
452
|
}
|
|
453
|
+
// `host:` placement — the job body runs on another machine over SSH; local
|
|
454
|
+
// version selection / sandbox / spawn do not apply. Sync callers (manual
|
|
455
|
+
// `routines run`, catchup) follow the remote run to completion.
|
|
456
|
+
if (config.host) {
|
|
457
|
+
return executeJobOnHost(config, { detached: false });
|
|
458
|
+
}
|
|
451
459
|
// Command-mode: run a plain shell command directly (no agent, no rotation,
|
|
452
460
|
// no pinning, no sandbox overlay). Reuses the run-record machinery so
|
|
453
461
|
// list/runs/overdue keep working.
|
|
@@ -617,15 +625,67 @@ export async function executeJob(config, deps) {
|
|
|
617
625
|
timer.end({ status: 'failed', exitCode: 1, runId });
|
|
618
626
|
return { meta, reportPath: null };
|
|
619
627
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
628
|
+
async function executeJobOnHost(config, opts) {
|
|
629
|
+
if (config.workflow) {
|
|
630
|
+
throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
|
|
631
|
+
}
|
|
632
|
+
if (config.loop) {
|
|
633
|
+
throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute on a host yet — remove 'host:' or 'loop:'.`);
|
|
634
|
+
}
|
|
635
|
+
if (config.command) {
|
|
636
|
+
throw new Error(`Routine '${config.name}' uses 'command:', which can't execute on a host yet — remove 'host:' or 'command:'.`);
|
|
637
|
+
}
|
|
638
|
+
const { resolveHostRunTarget, dispatchPromptToHost } = await import('./hosts/run-target.js');
|
|
639
|
+
const host = await resolveHostRunTarget(config.host);
|
|
640
|
+
const timer = createTimer('agent.run', {
|
|
641
|
+
agent: config.agent,
|
|
642
|
+
jobName: config.name,
|
|
643
|
+
mode: config.mode,
|
|
644
|
+
host: host.name,
|
|
645
|
+
...redactPrompt(config.prompt),
|
|
646
|
+
schedule: config.schedule,
|
|
647
|
+
});
|
|
648
|
+
const runId = generateRunId();
|
|
649
|
+
const runDir = getRunDir(config.name, runId);
|
|
650
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
651
|
+
const meta = {
|
|
652
|
+
jobName: config.name,
|
|
653
|
+
runId,
|
|
654
|
+
agent: config.agent,
|
|
655
|
+
pid: null, // no local process — the run lives on the host
|
|
656
|
+
spawnedAt: Date.now(),
|
|
657
|
+
status: 'running',
|
|
658
|
+
startedAt: new Date().toISOString(),
|
|
659
|
+
completedAt: null,
|
|
660
|
+
exitCode: null,
|
|
661
|
+
host: host.name,
|
|
662
|
+
};
|
|
663
|
+
writeRunMeta(meta);
|
|
664
|
+
const { task, exitCode } = await dispatchPromptToHost(host, {
|
|
665
|
+
agent: config.agent,
|
|
666
|
+
prompt: resolveJobPrompt(config),
|
|
667
|
+
mode: normalizeMode(config.mode),
|
|
668
|
+
effort: config.effort,
|
|
669
|
+
model: config.config?.model,
|
|
670
|
+
timeout: config.timeout, // enforced by the REMOTE agents run
|
|
671
|
+
remoteCwd: config.remoteCwd,
|
|
672
|
+
name: config.name,
|
|
673
|
+
cwd: runDir,
|
|
674
|
+
follow: !opts.detached,
|
|
675
|
+
});
|
|
676
|
+
meta.hostTaskId = task.id;
|
|
677
|
+
// Sync path: a real exit code finalizes now. -1 (follow window closed) and
|
|
678
|
+
// the detached path leave the meta `running` for the monitor to reconcile.
|
|
679
|
+
if (!opts.detached && exitCode !== null && exitCode !== undefined && exitCode !== -1) {
|
|
680
|
+
meta.status = exitCode === 0 ? 'completed' : 'failed';
|
|
681
|
+
meta.exitCode = exitCode;
|
|
682
|
+
meta.completedAt = new Date().toISOString();
|
|
683
|
+
}
|
|
684
|
+
writeRunMeta(meta);
|
|
685
|
+
timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
|
|
686
|
+
return { meta, reportPath: null };
|
|
687
|
+
}
|
|
688
|
+
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
629
689
|
async function executeCommandJobForeground(config) {
|
|
630
690
|
const timer = createTimer('agent.run', {
|
|
631
691
|
jobName: config.name,
|
|
@@ -712,12 +772,18 @@ async function executeCommandJobForeground(config) {
|
|
|
712
772
|
return { meta, reportPath: null };
|
|
713
773
|
}
|
|
714
774
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
775
|
+
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
715
776
|
export async function executeJobDetached(config) {
|
|
716
777
|
const eligibility = checkJobDeviceEligibility(config);
|
|
717
778
|
if (eligibility) {
|
|
718
779
|
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
719
780
|
throw new Error(eligibility.message);
|
|
720
781
|
}
|
|
782
|
+
// `host:` placement — dispatch over SSH and return; the monitor finalizes.
|
|
783
|
+
if (config.host) {
|
|
784
|
+
const { meta } = await executeJobOnHost(config, { detached: true });
|
|
785
|
+
return meta;
|
|
786
|
+
}
|
|
721
787
|
// Command-mode: fire a plain shell command detached (no agent, no rotation,
|
|
722
788
|
// no pinning, no sandbox overlay). Still writes a run record so the daemon,
|
|
723
789
|
// list/runs, and overdue tracking keep working.
|
|
@@ -994,6 +1060,26 @@ function isPidOurs(pid, spawnedAt) {
|
|
|
994
1060
|
return true;
|
|
995
1061
|
}
|
|
996
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Finalize one `host:`-placed run by healing its host-task sidecar against the
|
|
1065
|
+
* remote `.exit` (lib/hosts/reconcile.ts). Mutates + persists the meta only
|
|
1066
|
+
* when the sidecar reached a terminal state.
|
|
1067
|
+
*/
|
|
1068
|
+
function finalizeHostRun(meta) {
|
|
1069
|
+
try {
|
|
1070
|
+
const task = loadHostTask(meta.hostTaskId);
|
|
1071
|
+
if (!task)
|
|
1072
|
+
return;
|
|
1073
|
+
const healed = reconcileHostTask(task);
|
|
1074
|
+
if (healed.status !== 'completed' && healed.status !== 'failed')
|
|
1075
|
+
return;
|
|
1076
|
+
meta.status = healed.status;
|
|
1077
|
+
meta.exitCode = healed.exitCode ?? (healed.status === 'completed' ? 0 : 1);
|
|
1078
|
+
meta.completedAt = healed.finishedAt ?? new Date().toISOString();
|
|
1079
|
+
writeRunMeta(meta);
|
|
1080
|
+
}
|
|
1081
|
+
catch { /* unreachable host or unreadable sidecar — retry next sweep */ }
|
|
1082
|
+
}
|
|
997
1083
|
/** Scan all runs marked "running" and finalize any whose process has exited. */
|
|
998
1084
|
export function monitorRunningJobs() {
|
|
999
1085
|
const runsDir = getRunsDir();
|
|
@@ -1013,6 +1099,13 @@ export function monitorRunningJobs() {
|
|
|
1013
1099
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
1014
1100
|
if (meta.status !== 'running')
|
|
1015
1101
|
continue;
|
|
1102
|
+
// `host:`-placed run — no local pid to watch. Reconcile against the
|
|
1103
|
+
// remote `.exit` (completion is confirmed, never guessed: an
|
|
1104
|
+
// unreachable host leaves the run `running` for the next sweep).
|
|
1105
|
+
if (meta.hostTaskId) {
|
|
1106
|
+
finalizeHostRun(meta);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1016
1109
|
if (!meta.pid)
|
|
1017
1110
|
continue;
|
|
1018
1111
|
const runDirPath = path.join(jobRunsPath, runDirEntry.name);
|
|
@@ -207,6 +207,15 @@ export declare function agentGetSync(name: string): {
|
|
|
207
207
|
bundle: SecretsBundle;
|
|
208
208
|
env: Record<string, string>;
|
|
209
209
|
} | null;
|
|
210
|
+
/**
|
|
211
|
+
* Synchronous liveness check: is a broker actually LISTENING and answering (not
|
|
212
|
+
* just a lingering socket file)? Used to decide whether the auto-cache may take
|
|
213
|
+
* the synchronous warm path — a dead broker whose socket outlived it (crash,
|
|
214
|
+
* OOM, version-skew teardown) must NOT drag a foreground read through the
|
|
215
|
+
* worker's 20s cold-start budget. A stale socket refuses instantly, so this is
|
|
216
|
+
* fast in both the alive and dead cases. macOS only.
|
|
217
|
+
*/
|
|
218
|
+
export declare function agentReachableSync(): boolean;
|
|
210
219
|
/**
|
|
211
220
|
* Synchronously evict one bundle from the broker. Called after a mutating
|
|
212
221
|
* keychain write (add / rotate / remove / rename / delete) so the broker never
|
|
@@ -237,25 +246,54 @@ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: Secr
|
|
|
237
246
|
* per ~7d), so auto-caching is ON by default; opt out with
|
|
238
247
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
239
248
|
export declare function secretsAgentAutoEnabled(): boolean;
|
|
249
|
+
/** Minimum / maximum bounds for the configurable hold window. A too-small value
|
|
250
|
+
* would defeat the broker (constant re-prompts); a too-large one pins secrets in
|
|
251
|
+
* memory far longer than intended. */
|
|
252
|
+
export declare const MIN_HOLD_MS: number;
|
|
253
|
+
export declare const MAX_HOLD_MS: number;
|
|
254
|
+
/**
|
|
255
|
+
* How long an unlocked / auto-cached bundle is held before the next read
|
|
256
|
+
* re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
|
|
257
|
+
* `secrets.agent.holdMs` (milliseconds) in agents.yaml — e.g. 86400000 for a 24h
|
|
258
|
+
* cap. Clamped to [MIN_HOLD_MS, MAX_HOLD_MS] so a typo can neither disable the
|
|
259
|
+
* hold nor pin a secret in memory indefinitely. Best-effort: an unreadable or
|
|
260
|
+
* non-numeric value falls back to the 7d default. Pure except for the meta read.
|
|
261
|
+
*/
|
|
262
|
+
export declare function secretsHoldMs(): number;
|
|
263
|
+
/** Pure clamp for a configured `holdMs`: a positive finite number is bounded to
|
|
264
|
+
* [MIN_HOLD_MS, MAX_HOLD_MS]; anything else (absent, 0, negative, NaN, non-number)
|
|
265
|
+
* falls back to the 7d default. Exported for direct unit testing. */
|
|
266
|
+
export declare function clampHoldMs(v: unknown): number;
|
|
240
267
|
/**
|
|
241
268
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
242
269
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
243
|
-
* real keychain read of a `daily`-policy bundle
|
|
244
|
-
*
|
|
245
|
-
*
|
|
270
|
+
* real keychain read of a `daily`-policy bundle, so the NEXT concurrent read is
|
|
271
|
+
* silent. Env travels over stdin, never argv.
|
|
272
|
+
*
|
|
273
|
+
* Reliability (this is what makes `daily` actually "stick"): when a broker is
|
|
274
|
+
* ALREADY listening, warm it SYNCHRONOUSLY with a bounded wait so the bundle is
|
|
275
|
+
* held by the time this process exits. The old detached-only path lost the race
|
|
276
|
+
* under load — a short-lived reader (`agents secrets export`, a release-script
|
|
277
|
+
* loop) exited before the unref'd worker connected, so the cache silently never
|
|
278
|
+
* populated and every read re-prompted despite the `daily` policy. Only when the
|
|
279
|
+
* broker must COLD-START (no socket yet) do we fall back to the detached worker,
|
|
280
|
+
* so a first-ever read never blocks on a multi-second broker boot.
|
|
246
281
|
*
|
|
247
|
-
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping
|
|
248
|
-
*
|
|
249
|
-
* broker is itself a cold-starting full CLI and can take several seconds to bind
|
|
250
|
-
* the socket, so a short fixed budget would give up before it's ready and the
|
|
251
|
-
* cache would silently never populate. Best-effort; never throws. macOS only.
|
|
282
|
+
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
283
|
+
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
252
284
|
*/
|
|
253
285
|
export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
|
|
254
286
|
/**
|
|
255
287
|
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
256
288
|
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
257
|
-
* budget), and loads the bundle into it.
|
|
258
|
-
*
|
|
289
|
+
* budget), and loads the bundle into it.
|
|
290
|
+
*
|
|
291
|
+
* Exit code is load-truthful: 0 ONLY when the bundle was actually loaded into a
|
|
292
|
+
* reachable broker; non-zero on any failure (malformed payload, broker couldn't
|
|
293
|
+
* be brought up, or the load transport failed). The synchronous caller
|
|
294
|
+
* (agentAutoLoadSync) relies on this to decide whether to skip the detached
|
|
295
|
+
* fallback — a bare "process exited 0" would otherwise be a false-positive
|
|
296
|
+
* success that silently reintroduces the very re-prompt storm this path fixes.
|
|
259
297
|
*/
|
|
260
298
|
export declare function runAgentLoadFromStdin(): Promise<void>;
|
|
261
299
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
@@ -723,6 +723,46 @@ export function agentGetSync(name) {
|
|
|
723
723
|
return null;
|
|
724
724
|
}
|
|
725
725
|
}
|
|
726
|
+
/** Inline node program for a synchronous liveness ping. Connects, sends one
|
|
727
|
+
* `{cmd:'ping'}`, exits 0 iff a valid ping response comes back, else 3. A stale
|
|
728
|
+
* socket file with no listener refuses the connection immediately, so this
|
|
729
|
+
* fast-fails without riding any cold-start logic. argv after -e: [execPath, <socket>]. */
|
|
730
|
+
const SYNC_PING_PROGRAM = `
|
|
731
|
+
const net = require('net');
|
|
732
|
+
const sock = process.argv[1];
|
|
733
|
+
const c = net.createConnection(sock);
|
|
734
|
+
let buf = '';
|
|
735
|
+
const dead = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
|
|
736
|
+
const timer = setTimeout(dead, 700);
|
|
737
|
+
c.on('error', dead);
|
|
738
|
+
c.on('connect', () => c.write(JSON.stringify({ cmd: 'ping' }) + '\\n'));
|
|
739
|
+
c.setEncoding('utf-8');
|
|
740
|
+
c.on('data', (d) => {
|
|
741
|
+
buf += d;
|
|
742
|
+
const nl = buf.indexOf('\\n');
|
|
743
|
+
if (nl < 0) return;
|
|
744
|
+
clearTimeout(timer);
|
|
745
|
+
let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return dead(); }
|
|
746
|
+
try { c.destroy(); } catch (e) {}
|
|
747
|
+
process.exit(r && r.ok && r.cmd === 'ping' ? 0 : 3);
|
|
748
|
+
});
|
|
749
|
+
`;
|
|
750
|
+
/**
|
|
751
|
+
* Synchronous liveness check: is a broker actually LISTENING and answering (not
|
|
752
|
+
* just a lingering socket file)? Used to decide whether the auto-cache may take
|
|
753
|
+
* the synchronous warm path — a dead broker whose socket outlived it (crash,
|
|
754
|
+
* OOM, version-skew teardown) must NOT drag a foreground read through the
|
|
755
|
+
* worker's 20s cold-start budget. A stale socket refuses instantly, so this is
|
|
756
|
+
* fast in both the alive and dead cases. macOS only.
|
|
757
|
+
*/
|
|
758
|
+
export function agentReachableSync() {
|
|
759
|
+
if (!onDarwin())
|
|
760
|
+
return false;
|
|
761
|
+
if (!agentSocketExists())
|
|
762
|
+
return false;
|
|
763
|
+
const r = spawnSync(process.execPath, ['-e', SYNC_PING_PROGRAM, socketPath()], { timeout: 1500 });
|
|
764
|
+
return r.status === 0 && !r.error;
|
|
765
|
+
}
|
|
726
766
|
/**
|
|
727
767
|
* Inline node program for the synchronous evict path. Mirrors SYNC_GET_PROGRAM:
|
|
728
768
|
* writeBundle is synchronous and called synchronously everywhere, so a stale
|
|
@@ -817,26 +857,81 @@ export function secretsAgentAutoEnabled() {
|
|
|
817
857
|
return true;
|
|
818
858
|
}
|
|
819
859
|
}
|
|
860
|
+
/** Minimum / maximum bounds for the configurable hold window. A too-small value
|
|
861
|
+
* would defeat the broker (constant re-prompts); a too-large one pins secrets in
|
|
862
|
+
* memory far longer than intended. */
|
|
863
|
+
export const MIN_HOLD_MS = 60 * 1000; // 1m
|
|
864
|
+
export const MAX_HOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30d
|
|
865
|
+
/**
|
|
866
|
+
* How long an unlocked / auto-cached bundle is held before the next read
|
|
867
|
+
* re-prompts. Defaults to DEFAULT_TTL_MS (7d); override with
|
|
868
|
+
* `secrets.agent.holdMs` (milliseconds) in agents.yaml — e.g. 86400000 for a 24h
|
|
869
|
+
* cap. Clamped to [MIN_HOLD_MS, MAX_HOLD_MS] so a typo can neither disable the
|
|
870
|
+
* hold nor pin a secret in memory indefinitely. Best-effort: an unreadable or
|
|
871
|
+
* non-numeric value falls back to the 7d default. Pure except for the meta read.
|
|
872
|
+
*/
|
|
873
|
+
export function secretsHoldMs() {
|
|
874
|
+
try {
|
|
875
|
+
return clampHoldMs(readMeta().secrets?.agent?.holdMs);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return DEFAULT_TTL_MS;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/** Pure clamp for a configured `holdMs`: a positive finite number is bounded to
|
|
882
|
+
* [MIN_HOLD_MS, MAX_HOLD_MS]; anything else (absent, 0, negative, NaN, non-number)
|
|
883
|
+
* falls back to the 7d default. Exported for direct unit testing. */
|
|
884
|
+
export function clampHoldMs(v) {
|
|
885
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
|
|
886
|
+
return Math.min(Math.max(Math.floor(v), MIN_HOLD_MS), MAX_HOLD_MS);
|
|
887
|
+
}
|
|
888
|
+
return DEFAULT_TTL_MS;
|
|
889
|
+
}
|
|
820
890
|
/**
|
|
821
891
|
* Fire-and-forget: populate the broker with a freshly-resolved bundle so the
|
|
822
892
|
* NEXT process reads it without a prompt. Used by the auto-cache path after a
|
|
823
|
-
* real keychain read of a `daily`-policy bundle
|
|
824
|
-
*
|
|
825
|
-
* over stdin, never argv) and returns immediately.
|
|
893
|
+
* real keychain read of a `daily`-policy bundle, so the NEXT concurrent read is
|
|
894
|
+
* silent. Env travels over stdin, never argv.
|
|
826
895
|
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
896
|
+
* Reliability (this is what makes `daily` actually "stick"): when a broker is
|
|
897
|
+
* ALREADY listening, warm it SYNCHRONOUSLY with a bounded wait so the bundle is
|
|
898
|
+
* held by the time this process exits. The old detached-only path lost the race
|
|
899
|
+
* under load — a short-lived reader (`agents secrets export`, a release-script
|
|
900
|
+
* loop) exited before the unref'd worker connected, so the cache silently never
|
|
901
|
+
* populated and every read re-prompted despite the `daily` policy. Only when the
|
|
902
|
+
* broker must COLD-START (no socket yet) do we fall back to the detached worker,
|
|
903
|
+
* so a first-ever read never blocks on a multi-second broker boot.
|
|
904
|
+
*
|
|
905
|
+
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
906
|
+
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
832
907
|
*/
|
|
833
908
|
export function agentAutoLoadSync(name, bundle, env, ttlMs) {
|
|
834
909
|
if (!onDarwin())
|
|
835
910
|
return;
|
|
911
|
+
const payload = JSON.stringify({ name, bundle, env, ttlMs });
|
|
912
|
+
// Broker actually LISTENING → deterministic synchronous warm (bounded; the read
|
|
913
|
+
// already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
|
|
914
|
+
// ping, NOT mere socket-file existence: a broker that died leaving its socket
|
|
915
|
+
// behind (crash, OOM, or the version-skew teardown in this file) would otherwise
|
|
916
|
+
// drag this FOREGROUND read through the worker's 20s cold-start budget on every
|
|
917
|
+
// read. A dead/stale socket fails the ping fast, so we drop straight to the
|
|
918
|
+
// detached path (which does the cold-start + stale-socket cleanup off the hot
|
|
919
|
+
// path) — restoring "a dead broker costs the foreground read nothing".
|
|
920
|
+
if (agentReachableSync()) {
|
|
921
|
+
try {
|
|
922
|
+
const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
|
|
923
|
+
const r = spawnSync(cmd, args, { input: payload, timeout: 3000, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
924
|
+
if (!r.error && r.status === 0)
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
// fall through to the detached best-effort path
|
|
929
|
+
}
|
|
930
|
+
}
|
|
836
931
|
try {
|
|
837
932
|
const { cmd, args } = cliSpawn(['secrets', '_agent-load']);
|
|
838
933
|
const worker = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
|
|
839
|
-
worker.stdin?.write(
|
|
934
|
+
worker.stdin?.write(payload);
|
|
840
935
|
worker.stdin?.end();
|
|
841
936
|
worker.unref();
|
|
842
937
|
}
|
|
@@ -847,8 +942,14 @@ export function agentAutoLoadSync(name, bundle, env, ttlMs) {
|
|
|
847
942
|
/**
|
|
848
943
|
* Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
|
|
849
944
|
* env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
|
|
850
|
-
* budget), and loads the bundle into it.
|
|
851
|
-
*
|
|
945
|
+
* budget), and loads the bundle into it.
|
|
946
|
+
*
|
|
947
|
+
* Exit code is load-truthful: 0 ONLY when the bundle was actually loaded into a
|
|
948
|
+
* reachable broker; non-zero on any failure (malformed payload, broker couldn't
|
|
949
|
+
* be brought up, or the load transport failed). The synchronous caller
|
|
950
|
+
* (agentAutoLoadSync) relies on this to decide whether to skip the detached
|
|
951
|
+
* fallback — a bare "process exited 0" would otherwise be a false-positive
|
|
952
|
+
* success that silently reintroduces the very re-prompt storm this path fixes.
|
|
852
953
|
*/
|
|
853
954
|
export async function runAgentLoadFromStdin() {
|
|
854
955
|
if (!onDarwin())
|
|
@@ -861,15 +962,22 @@ export async function runAgentLoadFromStdin() {
|
|
|
861
962
|
payload = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
862
963
|
}
|
|
863
964
|
catch {
|
|
864
|
-
|
|
965
|
+
process.exitCode = 1; // malformed payload — nothing loaded
|
|
966
|
+
return;
|
|
865
967
|
}
|
|
866
|
-
if (!payload || !payload.name || !payload.bundle || !payload.env)
|
|
968
|
+
if (!payload || !payload.name || !payload.bundle || !payload.env) {
|
|
969
|
+
process.exitCode = 1;
|
|
867
970
|
return;
|
|
971
|
+
}
|
|
868
972
|
// Generous budget: the broker is a cold-starting full CLI; under load it can
|
|
869
973
|
// take several seconds to bind. We're detached, so waiting costs nothing.
|
|
870
|
-
if (!(await ensureAgentRunning(20000)))
|
|
974
|
+
if (!(await ensureAgentRunning(20000))) {
|
|
975
|
+
process.exitCode = 1; // broker couldn't be brought up — did NOT load
|
|
871
976
|
return;
|
|
872
|
-
|
|
977
|
+
}
|
|
978
|
+
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
|
|
979
|
+
if (!loaded)
|
|
980
|
+
process.exitCode = 1; // transport failed — did NOT load
|
|
873
981
|
}
|
|
874
982
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
875
983
|
export async function agentLoad(name, bundle, env, ttlMs) {
|
|
@@ -221,6 +221,32 @@ export declare function assertRemoteBundleFlagsUnsupported(bundleName: string, h
|
|
|
221
221
|
allowExpiredFlag: string;
|
|
222
222
|
}): void;
|
|
223
223
|
export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveBundleOptions): Record<string, string>;
|
|
224
|
+
/**
|
|
225
|
+
* True when the current process is a background / non-interactive context that
|
|
226
|
+
* must NEVER raise a Keychain biometry prompt on the interactive user's screen —
|
|
227
|
+
* a prompt nobody is watching. Two signals, either sufficient:
|
|
228
|
+
* - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
|
|
229
|
+
* `agents run --headless`, scheduled routines, and teammates — see
|
|
230
|
+
* exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
|
|
231
|
+
* - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
|
|
232
|
+
* stdio is redirected to a log — e.g. a release script run in the
|
|
233
|
+
* background as `( ... ) >log 2>&1 </dev/null`).
|
|
234
|
+
* `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
|
|
235
|
+
* even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
|
|
236
|
+
* keeps its terminal stdin, so it is NOT classified headless and still prompts.
|
|
237
|
+
*
|
|
238
|
+
* Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
|
|
239
|
+
* broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
|
|
240
|
+
* the Windows credential store resolve without any prompt. So off-darwin this
|
|
241
|
+
* ALWAYS returns false: forcing broker-only there would break every headless
|
|
242
|
+
* Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
|
|
243
|
+
* release flow) for no benefit — there is no prompt to suppress.
|
|
244
|
+
*
|
|
245
|
+
* A read in a macOS headless context resolves broker-only (agentOnly) and fails
|
|
246
|
+
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
247
|
+
* the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
|
|
248
|
+
*/
|
|
249
|
+
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
224
250
|
/**
|
|
225
251
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
226
252
|
*
|
|
@@ -25,7 +25,7 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
|
|
|
25
25
|
import { fileStore } from './filestore.js';
|
|
26
26
|
import { emit } from '../events.js';
|
|
27
27
|
import { readMeta } from '../state.js';
|
|
28
|
-
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled,
|
|
28
|
+
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
30
|
const keychainStore = {
|
|
31
31
|
has: hasKeychainToken,
|
|
@@ -492,8 +492,11 @@ export function listBundles() {
|
|
|
492
492
|
for (const bundle of keychainBundles)
|
|
493
493
|
out.push(bundle);
|
|
494
494
|
// Populate the broker for the rest of the hold window (fire-and-forget).
|
|
495
|
+
// Same configurable cap as the value read-path — otherwise `secrets list`
|
|
496
|
+
// would keep serving a stale metadata snapshot for 7d even when the user
|
|
497
|
+
// capped the hold at 24h via secrets.agent.holdMs.
|
|
495
498
|
if (useAgent && keychainBundles.length > 0) {
|
|
496
|
-
agentAutoLoadMetaSync(nameSetHash, keychainBundles,
|
|
499
|
+
agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
|
|
497
500
|
}
|
|
498
501
|
}
|
|
499
502
|
}
|
|
@@ -705,6 +708,44 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
705
708
|
void _opts.caller;
|
|
706
709
|
return env;
|
|
707
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* True when the current process is a background / non-interactive context that
|
|
713
|
+
* must NEVER raise a Keychain biometry prompt on the interactive user's screen —
|
|
714
|
+
* a prompt nobody is watching. Two signals, either sufficient:
|
|
715
|
+
* - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
|
|
716
|
+
* `agents run --headless`, scheduled routines, and teammates — see
|
|
717
|
+
* exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
|
|
718
|
+
* - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
|
|
719
|
+
* stdio is redirected to a log — e.g. a release script run in the
|
|
720
|
+
* background as `( ... ) >log 2>&1 </dev/null`).
|
|
721
|
+
* `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
|
|
722
|
+
* even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
|
|
723
|
+
* keeps its terminal stdin, so it is NOT classified headless and still prompts.
|
|
724
|
+
*
|
|
725
|
+
* Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
|
|
726
|
+
* broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
|
|
727
|
+
* the Windows credential store resolve without any prompt. So off-darwin this
|
|
728
|
+
* ALWAYS returns false: forcing broker-only there would break every headless
|
|
729
|
+
* Linux/Windows read (CI, `agents run --headless`, routines, the Linux-driven
|
|
730
|
+
* release flow) for no benefit — there is no prompt to suppress.
|
|
731
|
+
*
|
|
732
|
+
* A read in a macOS headless context resolves broker-only (agentOnly) and fails
|
|
733
|
+
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
734
|
+
* the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
|
|
735
|
+
*/
|
|
736
|
+
export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
|
|
737
|
+
if (platform !== 'darwin')
|
|
738
|
+
return false; // no biometry prompt to suppress off-darwin
|
|
739
|
+
const override = env.AGENTS_SECRETS_NO_PROMPT;
|
|
740
|
+
if (override === '1')
|
|
741
|
+
return true;
|
|
742
|
+
if (override === '0')
|
|
743
|
+
return false;
|
|
744
|
+
const runtime = env.AGENTS_RUNTIME;
|
|
745
|
+
if (runtime === 'headless' || runtime === 'teams')
|
|
746
|
+
return true;
|
|
747
|
+
return !process.stdin.isTTY && !process.stdout.isTTY;
|
|
748
|
+
}
|
|
708
749
|
/**
|
|
709
750
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
710
751
|
*
|
|
@@ -747,8 +788,15 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
747
788
|
return filtered;
|
|
748
789
|
}
|
|
749
790
|
}
|
|
750
|
-
|
|
751
|
-
|
|
791
|
+
// Only keychain-backed bundles can pop a Touch ID prompt and are the only ones
|
|
792
|
+
// the broker ever holds. A file-backed bundle resolves via passphrase with no
|
|
793
|
+
// prompt, so agentOnly must never block it — the broker never holds file
|
|
794
|
+
// bundles, so the throw would fire unconditionally and break a legitimate read.
|
|
795
|
+
if (opts.agentOnly && backend === 'keychain') {
|
|
796
|
+
throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent, and this is a ` +
|
|
797
|
+
`headless/background process that must not raise a Touch ID prompt on the ` +
|
|
798
|
+
`interactive user's screen. Run 'agents secrets unlock ${name}' in a terminal ` +
|
|
799
|
+
`first, or set AGENTS_SECRETS_NO_PROMPT=0 to force an interactive prompt.`);
|
|
752
800
|
}
|
|
753
801
|
if (backend === 'file')
|
|
754
802
|
assertFileBackendUsable(name);
|
|
@@ -881,15 +929,18 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
881
929
|
emitReadAudit('success');
|
|
882
930
|
// Auto-cache: this was a real keychain read (the agent fast-path returned
|
|
883
931
|
// earlier on a hit). If the bundle opts into the `daily` policy and the user
|
|
884
|
-
// enabled `secrets.agent.auto`, populate the broker
|
|
885
|
-
//
|
|
886
|
-
//
|
|
932
|
+
// enabled `secrets.agent.auto`, populate the broker so the next concurrent
|
|
933
|
+
// run reads silently. Skipped when noAgent (e.g. `unlock`, which loads the
|
|
934
|
+
// agent itself). When a broker is already up this warms it synchronously
|
|
935
|
+
// (bounded ~3s) so `daily` reliably sticks; only a cold-start broker uses the
|
|
936
|
+
// detached fire-and-forget path (see agentAutoLoadSync). The costly Touch ID
|
|
937
|
+
// prompt already happened, so the bounded wait is invisible.
|
|
887
938
|
if (backend === 'keychain' &&
|
|
888
939
|
!opts.noAgent &&
|
|
889
940
|
process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
890
941
|
bundlePolicy(bundle) === 'daily' &&
|
|
891
942
|
secretsAgentAutoEnabled()) {
|
|
892
|
-
agentAutoLoadSync(name, bundle, env,
|
|
943
|
+
agentAutoLoadSync(name, bundle, env, secretsHoldMs());
|
|
893
944
|
}
|
|
894
945
|
return { bundle, env };
|
|
895
946
|
}
|
package/dist/lib/secrets/mcp.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* unit-testable in-process.
|
|
25
25
|
*/
|
|
26
26
|
import * as readline from 'readline';
|
|
27
|
-
import { listBundles, readAndResolveBundleEnv, readBundle, validateBundleName, } from './bundles.js';
|
|
27
|
+
import { listBundles, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, validateBundleName, } from './bundles.js';
|
|
28
28
|
/** MCP protocol revision this server negotiates. */
|
|
29
29
|
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
30
30
|
/** The single tool this server exposes. */
|
|
@@ -68,7 +68,9 @@ export function resolveSecret(bundle, key) {
|
|
|
68
68
|
throw new Error(`Key '${key}' not found in bundle '${bundle}'.` +
|
|
69
69
|
(available.length ? ` Available keys: ${available.join(', ')}.` : ' Bundle has no keys.'));
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
// The MCP get_secret tool is typically served by a background/headless agent
|
|
72
|
+
// process; resolve broker-only there so it never raises an unwatched prompt.
|
|
73
|
+
const { env } = readAndResolveBundleEnv(bundle, { caller: 'secrets-mcp', agentOnly: isHeadlessSecretsContext() });
|
|
72
74
|
const value = env[key];
|
|
73
75
|
if (value === undefined) {
|
|
74
76
|
throw new Error(`Key '${key}' in bundle '${bundle}' could not be resolved.`);
|
|
@@ -16,6 +16,23 @@
|
|
|
16
16
|
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
17
|
*/
|
|
18
18
|
import { type SshExecResult } from '../ssh-exec.js';
|
|
19
|
+
/**
|
|
20
|
+
* Trust boundary for a remote-resolved env map. A peer's `secrets export` output
|
|
21
|
+
* is untrusted input: a compromised or misconfigured host could return keys that
|
|
22
|
+
* silently reshape THIS process's behavior once merged into the agent env
|
|
23
|
+
* (bundles.ts:251 `sanitizeProcessEnv` only strips loader vars from process.env,
|
|
24
|
+
* never the remote bundle). Block the dangerous-override classes here — at the
|
|
25
|
+
* source — so every consumer (`run --secrets b@host`, `secrets exec --host`) is
|
|
26
|
+
* protected, not just one call site:
|
|
27
|
+
* - LD_* / DYLD_* / NODE_OPTIONS and the other loader/interpreter injections
|
|
28
|
+
* (reuses the canonical bundles.ts predicate);
|
|
29
|
+
* - GIT_* — GIT_SSH_COMMAND et al. hijack every git subprocess;
|
|
30
|
+
* - *_PROXY — HTTP(S)_PROXY / ALL_PROXY reroute outbound traffic (MITM);
|
|
31
|
+
* - *_BASE_URL — ANTHROPIC_BASE_URL / OPENAI_BASE_URL redirect the model API.
|
|
32
|
+
* These keys are already rejected on the ADD side (validateEnvKey for loaders),
|
|
33
|
+
* so a legitimate bundle never carries them — only a hostile peer would.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isDangerousRemoteEnvKey(name: string): boolean;
|
|
19
36
|
/**
|
|
20
37
|
* Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
|
|
21
38
|
* registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
|