@phnx-labs/agents-cli 1.20.77 → 1.20.82
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 +589 -0
- package/README.md +27 -25
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +9 -0
- package/dist/commands/activity.js +153 -24
- package/dist/commands/apply.js +11 -4
- package/dist/commands/cli.js +1 -1
- package/dist/commands/commands.d.ts +1 -1
- package/dist/commands/commands.js +7 -6
- package/dist/commands/defaults.js +3 -11
- package/dist/commands/doctor.d.ts +74 -0
- package/dist/commands/doctor.js +718 -40
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -8
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +87 -11
- package/dist/commands/harness.js +2 -2
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/logs.js +6 -0
- package/dist/commands/models.js +1 -1
- package/dist/commands/packages.js +6 -6
- package/dist/commands/permissions.js +1 -1
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/pull.d.ts +1 -1
- package/dist/commands/pull.js +4 -4
- package/dist/commands/repo.d.ts +78 -0
- package/dist/commands/repo.js +195 -11
- package/dist/commands/routines.js +16 -4
- package/dist/commands/rules.js +13 -12
- package/dist/commands/secrets.d.ts +3 -1
- package/dist/commands/secrets.js +108 -34
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-inject.d.ts +7 -4
- package/dist/commands/sessions-inject.js +18 -18
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +90 -7
- package/dist/commands/sessions-resume.d.ts +16 -0
- package/dist/commands/sessions-resume.js +14 -13
- package/dist/commands/sessions.d.ts +126 -1
- package/dist/commands/sessions.js +365 -42
- package/dist/commands/set.d.ts +15 -0
- package/dist/commands/set.js +79 -0
- package/dist/commands/setup-fleet.d.ts +21 -0
- package/dist/commands/setup-fleet.js +201 -0
- package/dist/commands/setup-secrets.d.ts +20 -0
- package/dist/commands/setup-secrets.js +223 -0
- package/dist/commands/setup.js +17 -3
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +9 -1
- package/dist/commands/sync.js +7 -2
- package/dist/commands/teams-picker.js +0 -1
- package/dist/commands/teams.d.ts +33 -1
- package/dist/commands/teams.js +208 -44
- package/dist/commands/versions.js +10 -5
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +11 -0
- package/dist/index.js +8 -13
- package/dist/lib/acp/harnesses.js +0 -7
- package/dist/lib/activity.d.ts +156 -0
- package/dist/lib/activity.js +282 -0
- package/dist/lib/agents.d.ts +9 -23
- package/dist/lib/agents.js +72 -100
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/browser/service.js +3 -0
- package/dist/lib/browser/types.d.ts +7 -0
- package/dist/lib/capabilities.js +6 -2
- package/dist/lib/crabbox/lease.js +2 -2
- package/dist/lib/crabbox/setup-copy.d.ts +4 -4
- package/dist/lib/crabbox/setup-copy.js +4 -4
- package/dist/lib/daemon.d.ts +26 -40
- package/dist/lib/daemon.js +63 -123
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/fleet.d.ts +1 -1
- package/dist/lib/devices/fleet.js +1 -1
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/doctor-diff.d.ts +20 -0
- package/dist/lib/doctor-diff.js +6 -1
- package/dist/lib/drift.d.ts +31 -11
- package/dist/lib/drift.js +58 -7
- package/dist/lib/events.d.ts +8 -1
- package/dist/lib/events.js +10 -1
- package/dist/lib/exec.js +35 -8
- package/dist/lib/feed-post.d.ts +28 -1
- package/dist/lib/feed-post.js +110 -2
- package/dist/lib/fleet/apply.js +8 -1
- package/dist/lib/fleet/auth-sync.d.ts +18 -1
- package/dist/lib/fleet/auth-sync.js +25 -11
- package/dist/lib/fleet/remote-login.js +5 -0
- package/dist/lib/git.d.ts +27 -14
- package/dist/lib/git.js +138 -43
- package/dist/lib/hooks.d.ts +42 -0
- package/dist/lib/hooks.js +137 -93
- package/dist/lib/hosts/dispatch.d.ts +28 -1
- package/dist/lib/hosts/dispatch.js +33 -5
- package/dist/lib/hosts/option.js +2 -2
- package/dist/lib/hosts/passthrough.d.ts +22 -1
- package/dist/lib/hosts/passthrough.js +238 -13
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/hosts/run-target.d.ts +2 -0
- package/dist/lib/hosts/run-target.js +1 -0
- package/dist/lib/hq/floor.d.ts +1 -1
- package/dist/lib/hq/floor.js +6 -1
- package/dist/lib/mcp.js +3 -71
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/menubar/notify-desktop.d.ts +15 -0
- package/dist/lib/menubar/notify-desktop.js +41 -5
- package/dist/lib/merged-resources.d.ts +11 -0
- package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
- package/dist/lib/permissions.d.ts +0 -32
- package/dist/lib/permissions.js +4 -157
- package/dist/lib/picker.js +1 -1
- package/dist/lib/platform/process.d.ts +16 -5
- package/dist/lib/platform/process.js +54 -0
- package/dist/lib/plugins.d.ts +0 -8
- package/dist/lib/plugins.js +4 -110
- package/dist/lib/project-resources.js +4 -1
- package/dist/lib/refresh.d.ts +5 -3
- package/dist/lib/refresh.js +7 -5
- package/dist/lib/resources/commands.js +4 -1
- package/dist/lib/resources/mcp.js +0 -4
- package/dist/lib/resources/permissions.d.ts +1 -1
- package/dist/lib/resources/permissions.js +1 -5
- package/dist/lib/resources/rules.js +4 -1
- package/dist/lib/resources/skills.js +4 -1
- package/dist/lib/resources/subagents.js +4 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/routines.d.ts +20 -0
- package/dist/lib/routines.js +6 -0
- package/dist/lib/run-defaults.d.ts +1 -0
- package/dist/lib/run-defaults.js +9 -0
- package/dist/lib/runner.d.ts +0 -12
- package/dist/lib/runner.js +71 -29
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +18 -0
- package/dist/lib/secrets/agent.js +131 -17
- package/dist/lib/secrets/audit.d.ts +46 -0
- package/dist/lib/secrets/audit.js +56 -0
- package/dist/lib/secrets/bundles.d.ts +12 -9
- package/dist/lib/secrets/bundles.js +47 -31
- package/dist/lib/secrets/index.d.ts +2 -1
- package/dist/lib/secrets/index.js +8 -2
- package/dist/lib/secrets/remote.d.ts +75 -2
- package/dist/lib/secrets/remote.js +139 -5
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/secrets/sync.js +3 -3
- package/dist/lib/session/active.d.ts +117 -26
- package/dist/lib/session/active.js +199 -53
- package/dist/lib/session/actor-sidecar.d.ts +23 -0
- package/dist/lib/session/actor-sidecar.js +101 -0
- package/dist/lib/session/db.d.ts +41 -1
- package/dist/lib/session/db.js +173 -8
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +19 -2
- package/dist/lib/session/discover.js +32 -11
- package/dist/lib/session/hook-sessions.d.ts +9 -0
- package/dist/lib/session/hook-sessions.js +57 -8
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/pid-registry.d.ts +13 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +16 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/staleness/detectors/commands.js +2 -2
- package/dist/lib/staleness/detectors/permissions.js +1 -45
- package/dist/lib/staleness/writers/commands.js +4 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +1 -2
- package/dist/lib/startup/command-registry.js +2 -4
- package/dist/lib/subagents-registry.js +5 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -12
- package/dist/lib/teams/agents.d.ts +0 -2
- package/dist/lib/teams/agents.js +10 -27
- package/dist/lib/teams/index.d.ts +1 -1
- package/dist/lib/teams/index.js +1 -1
- package/dist/lib/types.d.ts +17 -9
- package/dist/lib/usage.d.ts +28 -5
- package/dist/lib/usage.js +271 -8
- package/dist/lib/versions.js +7 -1
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +25 -13
- package/package.json +1 -2
- package/dist/commands/check.d.ts +0 -15
- package/dist/commands/check.js +0 -180
- package/dist/commands/resources.d.ts +0 -5
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical audit emitter for `agents secrets` value access and unlock grants.
|
|
3
|
+
*
|
|
4
|
+
* Every path that reads a secret VALUE or grants an unlock funnels its audit
|
|
5
|
+
* through here, so the operational event stream — `agents events`, backed by the
|
|
6
|
+
* append-only `~/.agents/events.jsonl` audit log — carries a uniform, value-free
|
|
7
|
+
* provenance record: bundle, key NAMES, the resolving agent/harness identity,
|
|
8
|
+
* operation, source, status. The ts / host / session / caller fields are filled
|
|
9
|
+
* in by `emit()` itself. The secret VALUE is never part of the payload; this
|
|
10
|
+
* helper only ever receives metadata, and `emit()`'s `sanitizePayload` is a
|
|
11
|
+
* second redaction layer.
|
|
12
|
+
*
|
|
13
|
+
* Two event types, both audit-level and non-milestone (so they surface in
|
|
14
|
+
* `agents events` and the persisted audit trail, but are NOT required in the
|
|
15
|
+
* curated `agents activity` / `agents feed` surfaces):
|
|
16
|
+
* - `secrets.get` — a value was READ (exec inject, export, `view --reveal`,
|
|
17
|
+
* raw `get <item>`, remote resolve, sync push,
|
|
18
|
+
* `run --secrets`, and every other bundle read).
|
|
19
|
+
* - `secrets.unlocked` — a bundle was GRANTED into the secrets broker / durable
|
|
20
|
+
* session by `agents secrets unlock`, then readable
|
|
21
|
+
* prompt-free for the grant TTL.
|
|
22
|
+
*/
|
|
23
|
+
import { emit } from '../events.js';
|
|
24
|
+
/**
|
|
25
|
+
* The agent/harness identity to attribute a secret access to. Explicit callers
|
|
26
|
+
* (the bundle reader knows the scope it resolved under) win; otherwise fall back
|
|
27
|
+
* to the ambient `$AGENTS_AGENT_NAME` set on every agent launch.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveAuditAgent(explicit) {
|
|
30
|
+
const a = explicit || process.env.AGENTS_AGENT_NAME;
|
|
31
|
+
return a && a.length > 0 ? a : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Emit one value-free secret audit event to the raw/operational event stream and
|
|
35
|
+
* the persisted audit log. Only ever pass metadata — the secret value must never
|
|
36
|
+
* reach this function.
|
|
37
|
+
*/
|
|
38
|
+
export function emitSecretAudit(p) {
|
|
39
|
+
const agent = resolveAuditAgent(p.agent);
|
|
40
|
+
emit(p.event, {
|
|
41
|
+
module: 'secrets',
|
|
42
|
+
...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
|
|
43
|
+
...(p.item !== undefined ? { item: p.item } : {}),
|
|
44
|
+
...(p.operation !== undefined ? { operation: p.operation } : {}),
|
|
45
|
+
...(p.source !== undefined ? { source: p.source } : {}),
|
|
46
|
+
...(p.status !== undefined ? { status: p.status } : {}),
|
|
47
|
+
...(p.keys !== undefined ? { keys: p.keys } : {}),
|
|
48
|
+
...(p.keyCount !== undefined ? { keyCount: p.keyCount } : {}),
|
|
49
|
+
...(p.keychainKeys !== undefined ? { keychainKeys: p.keychainKeys } : {}),
|
|
50
|
+
...(p.kindCounts !== undefined ? { kindCounts: p.kindCounts } : {}),
|
|
51
|
+
...(agent !== undefined ? { agent } : {}),
|
|
52
|
+
...(p.host !== undefined ? { host: p.host } : {}),
|
|
53
|
+
...(p.ttlMs !== undefined ? { ttlMs: p.ttlMs } : {}),
|
|
54
|
+
...(p.error !== undefined ? { error: p.error } : {}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -43,7 +43,9 @@ export interface VarMeta {
|
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
45
|
* A bundle's prompt policy — how often macOS asks for Touch ID to read it:
|
|
46
|
-
* - `
|
|
46
|
+
* - `hold` (default): ask once, then serve it silently for the configured hold
|
|
47
|
+
* duration (`secrets.agent.holdMs`, 7d by default). Named for what it does —
|
|
48
|
+
* it was called `daily`, which stated a period it never had.
|
|
47
49
|
* (Historical name — the window is now a rolling ~1 week, not one calendar day.)
|
|
48
50
|
* Eligible for the secrets-agent — the first real keychain read auto-loads it
|
|
49
51
|
* (auto-cache is on by default) so concurrent runs read it silently, or `unlock`
|
|
@@ -61,12 +63,12 @@ export interface VarMeta {
|
|
|
61
63
|
* it loudly rather than silently downgrading to `always`.
|
|
62
64
|
*
|
|
63
65
|
* The default is configurable via `secrets.policy` in agents.yaml. Stored on disk
|
|
64
|
-
* under the legacy `tier` key (`session` == `
|
|
66
|
+
* under the legacy `tier` key (`session` == `hold`, `biometry` == explicit
|
|
65
67
|
* `always`, `none` == `never`, absent == inherit the default) so bundles stay
|
|
66
68
|
* readable across mixed CLI versions on synced machines. The user-facing
|
|
67
|
-
* vocabulary is `policy`/`always`/`
|
|
69
|
+
* vocabulary is `policy`/`always`/`hold`/`never`.
|
|
68
70
|
*/
|
|
69
|
-
export type SecretsPolicy = 'always' | '
|
|
71
|
+
export type SecretsPolicy = 'always' | 'hold' | 'never';
|
|
70
72
|
/** A named set of environment variable definitions backed by various secret providers. */
|
|
71
73
|
export interface SecretsBundle {
|
|
72
74
|
name: string;
|
|
@@ -74,7 +76,7 @@ export interface SecretsBundle {
|
|
|
74
76
|
allow_exec?: boolean;
|
|
75
77
|
/** Which store carries this bundle's items. Absent ⇒ `keychain` (the default). */
|
|
76
78
|
backend?: SecretsBackend;
|
|
77
|
-
/** Prompt policy. Absent ⇒ the configured default (`
|
|
79
|
+
/** Prompt policy. Absent ⇒ the configured default (`hold`). Serialized under
|
|
78
80
|
* the legacy `tier` key — see SecretsPolicy. */
|
|
79
81
|
policy?: SecretsPolicy;
|
|
80
82
|
/** ISO 8601 UTC timestamp. Set once on the first writeBundle() for a bundle. */
|
|
@@ -142,9 +144,10 @@ export declare class BundleUndecryptableError extends Error {
|
|
|
142
144
|
export declare function readBundleIfDecryptable(name: string): SecretsBundle | null;
|
|
143
145
|
export declare function readBundle(name: string): SecretsBundle;
|
|
144
146
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
145
|
-
* policy. Configurable via `secrets.policy` in agents.yaml; `
|
|
146
|
-
*
|
|
147
|
-
* `always`. Best-effort: an
|
|
147
|
+
* policy. Configurable via `secrets.policy` in agents.yaml; `hold` (one Touch ID
|
|
148
|
+
* per hold window — `secrets.agent.holdMs`, 7d by default) unless the user
|
|
149
|
+
* explicitly opts back into prompt-every-time with `always`. Best-effort: an
|
|
150
|
+
* unreadable config falls back to the `hold` default. */
|
|
148
151
|
export declare function secretsDefaultPolicy(): SecretsPolicy;
|
|
149
152
|
/** The effective prompt policy of a bundle (absent ⇒ the configured default). */
|
|
150
153
|
export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
|
|
@@ -301,7 +304,7 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
|
|
|
301
304
|
*
|
|
302
305
|
* A read in a macOS headless context resolves broker-only (agentOnly) and fails
|
|
303
306
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
304
|
-
* the per-caller pattern
|
|
307
|
+
* the per-caller broker-only pattern used across the headless secrets readers.
|
|
305
308
|
*/
|
|
306
309
|
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
|
|
307
310
|
stdin?: boolean;
|
|
@@ -28,10 +28,12 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
|
|
|
28
28
|
import { fileStore } from './filestore.js';
|
|
29
29
|
import { getVaultSession, vaultDeleteItem, vaultExists, vaultGetItems, vaultGetItem, vaultHasItem, vaultListItems, vaultSetItems, vaultSetItem, } from './vault.js';
|
|
30
30
|
import { emit } from '../events.js';
|
|
31
|
+
import { emitSecretAudit } from './audit.js';
|
|
31
32
|
import { readMeta, getHelpersDir } from '../state.js';
|
|
32
33
|
import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
|
|
33
34
|
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
|
|
34
|
-
import {
|
|
35
|
+
import { GLOBAL_HARNESS } from './scope.js';
|
|
36
|
+
import { resolveSession, deleteSession } from './session-store.js';
|
|
35
37
|
import { createHash } from 'node:crypto';
|
|
36
38
|
const keychainStore = {
|
|
37
39
|
has: hasKeychainToken,
|
|
@@ -323,7 +325,7 @@ export function readBundle(name) {
|
|
|
323
325
|
// Absent ⇒ keychain; only set when non-keychain so a keychain bundle
|
|
324
326
|
// round-trips byte-for-byte.
|
|
325
327
|
backend: backend === 'keychain' ? undefined : backend,
|
|
326
|
-
// Legacy wire key: the policy is persisted under `tier` (`session` == `
|
|
328
|
+
// Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
|
|
327
329
|
policy: parsePolicy(parsed.tier),
|
|
328
330
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
329
331
|
};
|
|
@@ -342,14 +344,14 @@ export function readBundle(name) {
|
|
|
342
344
|
return bundle;
|
|
343
345
|
}
|
|
344
346
|
/** Normalize the persisted prompt policy. The on-disk `tier` key uses legacy
|
|
345
|
-
* tokens for cross-version compatibility: `session` ⇒ `
|
|
347
|
+
* tokens for cross-version compatibility: `session` ⇒ `hold`, `biometry` ⇒ an
|
|
346
348
|
* explicit `always`. An absent token ⇒ undefined, which resolves to the
|
|
347
|
-
* configured default policy (`
|
|
348
|
-
* legacy `biometry` token keeps older CLIs correct — they don't know `
|
|
349
|
+
* configured default policy (`hold`). Persisting an explicit `always` as the
|
|
350
|
+
* legacy `biometry` token keeps older CLIs correct — they don't know `hold`,
|
|
349
351
|
* read `biometry` as undefined, and fall back to their own always default. */
|
|
350
352
|
function parsePolicy(raw) {
|
|
351
|
-
if (raw === 'daily' || raw === 'session')
|
|
352
|
-
return '
|
|
353
|
+
if (raw === 'hold' || raw === 'daily' || raw === 'session')
|
|
354
|
+
return 'hold';
|
|
353
355
|
if (raw === 'always' || raw === 'biometry')
|
|
354
356
|
return 'always';
|
|
355
357
|
if (raw === 'never' || raw === 'none')
|
|
@@ -357,15 +359,16 @@ function parsePolicy(raw) {
|
|
|
357
359
|
return undefined;
|
|
358
360
|
}
|
|
359
361
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
360
|
-
* policy. Configurable via `secrets.policy` in agents.yaml; `
|
|
361
|
-
*
|
|
362
|
-
* `always`. Best-effort: an
|
|
362
|
+
* policy. Configurable via `secrets.policy` in agents.yaml; `hold` (one Touch ID
|
|
363
|
+
* per hold window — `secrets.agent.holdMs`, 7d by default) unless the user
|
|
364
|
+
* explicitly opts back into prompt-every-time with `always`. Best-effort: an
|
|
365
|
+
* unreadable config falls back to the `hold` default. */
|
|
363
366
|
export function secretsDefaultPolicy() {
|
|
364
367
|
try {
|
|
365
|
-
return readMeta().secrets?.policy === 'always' ? 'always' : '
|
|
368
|
+
return readMeta().secrets?.policy === 'always' ? 'always' : 'hold';
|
|
366
369
|
}
|
|
367
370
|
catch {
|
|
368
|
-
return '
|
|
371
|
+
return 'hold';
|
|
369
372
|
}
|
|
370
373
|
}
|
|
371
374
|
/** The effective prompt policy of a bundle (absent ⇒ the configured default). */
|
|
@@ -433,12 +436,12 @@ function prepareBundleWrite(bundle) {
|
|
|
433
436
|
allow_exec: bundle.allow_exec ? true : undefined,
|
|
434
437
|
backend: backend === 'keychain' ? undefined : backend,
|
|
435
438
|
// Wire format: persist the policy under the legacy `tier` token so older CLI
|
|
436
|
-
// versions on other synced machines keep reading it — `
|
|
439
|
+
// versions on other synced machines keep reading it — `hold`⇒`session`,
|
|
437
440
|
// explicit `always`⇒`biometry`, `never`⇒`none`. An absent policy omits the
|
|
438
|
-
// token entirely and resolves to the configured default (`
|
|
441
|
+
// token entirely and resolves to the configured default (`hold`) on read.
|
|
439
442
|
// An older CLI that doesn't know `none` reads it as undefined and falls back
|
|
440
443
|
// to its own default — safe, since it also lacks the no-ACL write path.
|
|
441
|
-
tier: bundle.policy === '
|
|
444
|
+
tier: bundle.policy === 'hold' ? 'session'
|
|
442
445
|
: bundle.policy === 'always' ? 'biometry'
|
|
443
446
|
: bundle.policy === 'never' ? 'none'
|
|
444
447
|
: undefined,
|
|
@@ -545,7 +548,7 @@ function parseBundleMeta(nameHint, json, backend) {
|
|
|
545
548
|
description: parsed.description,
|
|
546
549
|
allow_exec: Boolean(parsed.allow_exec),
|
|
547
550
|
backend: backend === 'keychain' ? undefined : backend,
|
|
548
|
-
// Legacy wire key: the policy is persisted under `tier` (`session` == `
|
|
551
|
+
// Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
|
|
549
552
|
policy: parsePolicy(parsed.tier),
|
|
550
553
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
551
554
|
};
|
|
@@ -1043,7 +1046,7 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
1043
1046
|
*
|
|
1044
1047
|
* A read in a macOS headless context resolves broker-only (agentOnly) and fails
|
|
1045
1048
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
1046
|
-
* the per-caller pattern
|
|
1049
|
+
* the per-caller broker-only pattern used across the headless secrets readers.
|
|
1047
1050
|
*/
|
|
1048
1051
|
export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
|
|
1049
1052
|
// Injected so the TTY branch below is testable: it is the branch that decides a
|
|
@@ -1097,7 +1100,11 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1097
1100
|
// file-backed bundle has none to dedup. The never-unlocked path is a single
|
|
1098
1101
|
// stat (agentSocketExists) so it costs nothing when the agent isn't running.
|
|
1099
1102
|
if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1100
|
-
|
|
1103
|
+
// The scope this reader asks under. Falls back to the GLOBAL scope, not to a
|
|
1104
|
+
// literal `'cli'` harness — the broker and the durable store both resolve
|
|
1105
|
+
// own-harness → global (bundleScopeChain), so an unscoped unlock is visible
|
|
1106
|
+
// here whether this process was launched by an agent or typed in a terminal.
|
|
1107
|
+
const harness = opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS;
|
|
1101
1108
|
const hit = agentGetSync(name, harness);
|
|
1102
1109
|
if (hit) {
|
|
1103
1110
|
// The agent stores the FULL bundle env. Apply the same subset filter and
|
|
@@ -1106,13 +1113,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1106
1113
|
// the first cache-populating run.
|
|
1107
1114
|
const filtered = filterAgentHitBySubsetAndExpiry(hit, opts);
|
|
1108
1115
|
stampLastUsed(filtered.bundle);
|
|
1109
|
-
|
|
1110
|
-
|
|
1116
|
+
emitSecretAudit({
|
|
1117
|
+
event: 'secrets.get',
|
|
1111
1118
|
bundle: name,
|
|
1112
1119
|
operation: opts.caller,
|
|
1113
1120
|
status: 'success',
|
|
1114
1121
|
source: 'agent',
|
|
1115
1122
|
keyCount: Object.keys(filtered.env).length,
|
|
1123
|
+
agent: harness,
|
|
1116
1124
|
});
|
|
1117
1125
|
return filtered;
|
|
1118
1126
|
}
|
|
@@ -1122,20 +1130,24 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1122
1130
|
// Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
|
|
1123
1131
|
// across restart — this fixes BOTH the interactive re-prompt and the headless
|
|
1124
1132
|
// throw below (which now fires only when there is genuinely no session).
|
|
1125
|
-
const
|
|
1126
|
-
if (
|
|
1133
|
+
const resolved = resolveSession(name, Date.now(), harness);
|
|
1134
|
+
if (resolved) {
|
|
1135
|
+
const session = resolved.entry;
|
|
1127
1136
|
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
1128
1137
|
stampLastUsed(filtered.bundle);
|
|
1129
1138
|
// Re-warm the broker with the remaining TTL so later reads hit RAM and
|
|
1130
|
-
// `agents secrets status` is honest.
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1139
|
+
// `agents secrets status` is honest. Re-warm under the scope the grant was
|
|
1140
|
+
// MADE in (resolved.harness), never the asking scope — re-warming a global
|
|
1141
|
+
// grant as `claude` would silently narrow it for every other harness.
|
|
1142
|
+
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), resolved.harness);
|
|
1143
|
+
emitSecretAudit({
|
|
1144
|
+
event: 'secrets.get',
|
|
1134
1145
|
bundle: name,
|
|
1135
1146
|
operation: opts.caller,
|
|
1136
1147
|
status: 'success',
|
|
1137
1148
|
source: 'session',
|
|
1138
1149
|
keyCount: Object.keys(filtered.env).length,
|
|
1150
|
+
agent: harness,
|
|
1139
1151
|
});
|
|
1140
1152
|
return filtered;
|
|
1141
1153
|
}
|
|
@@ -1190,6 +1202,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1190
1202
|
? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
|
|
1191
1203
|
agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
|
|
1192
1204
|
bundle: name,
|
|
1205
|
+
// The session that triggered the read, so a Touch ID prompt is
|
|
1206
|
+
// attributable when several agents run at once. Exported by exec.ts.
|
|
1207
|
+
sessionId: process.env.AGENT_SESSION_ID || process.env.AGENTS_SESSION_ID,
|
|
1193
1208
|
reason: opts.caller ? `to ${opts.caller}` : reason,
|
|
1194
1209
|
duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
|
|
1195
1210
|
defaultPolicy: secretsDefaultPolicy(),
|
|
@@ -1225,7 +1240,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1225
1240
|
description: parsed.description,
|
|
1226
1241
|
allow_exec: Boolean(parsed.allow_exec),
|
|
1227
1242
|
backend: backend === 'keychain' ? undefined : backend,
|
|
1228
|
-
// Legacy wire key: the policy is persisted under `tier` (`session` == `
|
|
1243
|
+
// Legacy wire key: the policy is persisted under `tier` (`session` == `hold`).
|
|
1229
1244
|
policy: parsePolicy(parsed.tier),
|
|
1230
1245
|
vars: parsed.vars && typeof parsed.vars === 'object' ? parsed.vars : {},
|
|
1231
1246
|
};
|
|
@@ -1261,8 +1276,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1261
1276
|
const keys = [...selectedKeys].sort();
|
|
1262
1277
|
keychainKeys.sort();
|
|
1263
1278
|
const emitReadAudit = (status, err) => {
|
|
1264
|
-
|
|
1265
|
-
|
|
1279
|
+
emitSecretAudit({
|
|
1280
|
+
event: 'secrets.get',
|
|
1266
1281
|
bundle: bundle.name,
|
|
1267
1282
|
operation: opts.caller,
|
|
1268
1283
|
status,
|
|
@@ -1270,6 +1285,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1270
1285
|
keys,
|
|
1271
1286
|
keychainKeys,
|
|
1272
1287
|
kindCounts,
|
|
1288
|
+
agent: opts.agent,
|
|
1273
1289
|
error: err instanceof Error ? err.message : (err ? String(err) : undefined),
|
|
1274
1290
|
});
|
|
1275
1291
|
};
|
|
@@ -1320,10 +1336,10 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1320
1336
|
if (backend === 'keychain' &&
|
|
1321
1337
|
!opts.noAgent &&
|
|
1322
1338
|
process.env.AGENTS_SECRETS_NO_AGENT !== '1' &&
|
|
1323
|
-
bundlePolicy(bundle) === '
|
|
1339
|
+
bundlePolicy(bundle) === 'hold' &&
|
|
1324
1340
|
secretsAgentAutoEnabled() &&
|
|
1325
1341
|
canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
|
|
1326
|
-
agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME ||
|
|
1342
|
+
agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || GLOBAL_HARNESS);
|
|
1327
1343
|
}
|
|
1328
1344
|
return { bundle, env };
|
|
1329
1345
|
}
|
|
@@ -213,9 +213,10 @@ export declare function hasKeychainToken(item: string): boolean;
|
|
|
213
213
|
export interface KeychainReadContext {
|
|
214
214
|
agent?: string;
|
|
215
215
|
bundle?: string;
|
|
216
|
+
sessionId?: string;
|
|
216
217
|
reason?: string;
|
|
217
218
|
duration?: string;
|
|
218
|
-
defaultPolicy?: '
|
|
219
|
+
defaultPolicy?: 'hold' | 'always' | 'never';
|
|
219
220
|
forceDuration?: boolean;
|
|
220
221
|
}
|
|
221
222
|
export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
|
|
@@ -30,6 +30,7 @@ import * as path from 'path';
|
|
|
30
30
|
import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
|
|
31
31
|
import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
|
|
32
32
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
33
|
+
import { deriveShortId } from '../session/short-id.js';
|
|
33
34
|
const SERVICE_PREFIX = 'agents-cli';
|
|
34
35
|
export const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
|
|
35
36
|
const BUNDLES_ITEM_PREFIX = `${SERVICE_PREFIX}.bundles.`;
|
|
@@ -710,9 +711,12 @@ export function hasKeychainToken(item) {
|
|
|
710
711
|
export function keychainOperationPrompt(context = {}) {
|
|
711
712
|
const agent = context.agent || 'Agents CLI';
|
|
712
713
|
const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
|
|
714
|
+
// Which session triggered the read — the short-id disambiguates an unexpected
|
|
715
|
+
// prompt when several agents run at once (interactive + headless + exec).
|
|
716
|
+
const session = context.sessionId ? ` (session ${deriveShortId(context.sessionId)})` : '';
|
|
713
717
|
const duration = context.duration ? ` for ${context.duration}` : '';
|
|
714
718
|
const reason = context.reason ? ` ${context.reason}` : '';
|
|
715
|
-
return `${agent} is requesting to unlock${bundle}${duration}${reason}.`;
|
|
719
|
+
return `${agent} is requesting to unlock${bundle}${session}${duration}${reason}.`;
|
|
716
720
|
}
|
|
717
721
|
export function getKeychainToken(item, context = {}) {
|
|
718
722
|
// Errors keep the requested (human-readable) name; the storage name may be
|
|
@@ -820,7 +824,9 @@ export function getKeychainTokens(items, context = {}) {
|
|
|
820
824
|
...process.env,
|
|
821
825
|
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
822
826
|
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
823
|
-
|
|
827
|
+
// The signed helper's own vocabulary is unchanged (it predates the rename
|
|
828
|
+
// and ships as a separately-versioned binary), so map to its legacy token.
|
|
829
|
+
AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
|
|
824
830
|
AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
|
|
825
831
|
},
|
|
826
832
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -43,12 +43,17 @@ export declare function isDangerousRemoteEnvKey(name: string): boolean;
|
|
|
43
43
|
*/
|
|
44
44
|
export declare function resolveHostSshTarget(nameOrAlias: string): Promise<string>;
|
|
45
45
|
/**
|
|
46
|
-
* Merge `--host <single>`
|
|
47
|
-
* list.
|
|
46
|
+
* Merge `--host <single>` / `--hosts <a,b,c>` (and their `--device` / `--devices`
|
|
47
|
+
* aliases) into an ordered, de-duplicated list. All four flags compose; any alone
|
|
48
|
+
* works. `--device`/`--devices` resolve identically to `--host`/`--hosts` so the
|
|
49
|
+
* fleet-wide `--device` vocabulary (see `agents activity`, `agents run --device`)
|
|
50
|
+
* works on the secrets remote commands too. Empty when none is set.
|
|
48
51
|
*/
|
|
49
52
|
export declare function parseHostsOption(opts: {
|
|
50
53
|
host?: string;
|
|
51
54
|
hosts?: string;
|
|
55
|
+
device?: string;
|
|
56
|
+
devices?: string;
|
|
52
57
|
}): string[];
|
|
53
58
|
/**
|
|
54
59
|
* Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
|
|
@@ -104,3 +109,71 @@ export declare function remoteSecretsStream(target: string, args: string[], opts
|
|
|
104
109
|
export declare function remoteResolveEnv(target: string, bundle: string, opts?: {
|
|
105
110
|
osLookupName?: string;
|
|
106
111
|
}): Promise<Record<string, string>>;
|
|
112
|
+
/**
|
|
113
|
+
* Outcome of a post-push read-back verification (see verifyRemoteKeychainPush).
|
|
114
|
+
* - `ok` — the pushed keys materialized readably on the remote.
|
|
115
|
+
* - `locked-keychain` — the read-back gave the SPECIFIC signal of a keychain
|
|
116
|
+
* that didn't persist (the remote's headless "not unlocked
|
|
117
|
+
* in the secrets agent" guard, or a "stored item … not
|
|
118
|
+
* found" on read-back, or pushed keys simply absent). Only
|
|
119
|
+
* this verdict earns the locked-login-keychain diagnosis +
|
|
120
|
+
* `--remote-backend file` steer.
|
|
121
|
+
* - `error` — a DIFFERENT failure (flaky SSH, timeout, unparseable
|
|
122
|
+
* payload). The raw error is re-surfaced verbatim, never
|
|
123
|
+
* mislabeled as a locked keychain.
|
|
124
|
+
*/
|
|
125
|
+
export type RemoteKeychainWriteVerification = {
|
|
126
|
+
ok: true;
|
|
127
|
+
} | {
|
|
128
|
+
ok: false;
|
|
129
|
+
kind: 'locked-keychain';
|
|
130
|
+
reason: string;
|
|
131
|
+
} | {
|
|
132
|
+
ok: false;
|
|
133
|
+
kind: 'error';
|
|
134
|
+
reason: string;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Decide whether a keychain-backed push to a remote actually PERSISTED its secret
|
|
138
|
+
* value items, given the read-back of that bundle from the remote's own store.
|
|
139
|
+
*
|
|
140
|
+
* The silent-failure this guards: pushing `--remote-backend keychain` (default) to
|
|
141
|
+
* a macOS host over headless SSH lands the bundle METADATA but not readable value
|
|
142
|
+
* items — the remote login keychain is locked in the non-interactive SSH context,
|
|
143
|
+
* so Security accepts the item WRITE at the DB level but the biometry-ACL'd item is
|
|
144
|
+
* unreadable, and the remote `import` still reports success (values written first,
|
|
145
|
+
* metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
|
|
146
|
+
* then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
|
|
147
|
+
* item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
|
|
148
|
+
* the bundle back the same way a release will (`secrets export --plaintext --format
|
|
149
|
+
* json`, driven headlessly on the remote so its `agentOnly` guard FAILS FAST before
|
|
150
|
+
* any keychain read — no Touch ID prompt) and confirming every pushed key returned.
|
|
151
|
+
*
|
|
152
|
+
* Pure so both branches are unit-testable without a real locked keychain: inject the
|
|
153
|
+
* "read-back failed / key absent" condition through `readBack`.
|
|
154
|
+
*/
|
|
155
|
+
export declare function evaluateKeychainWriteVerification(pushedKeys: string[], readBack: {
|
|
156
|
+
ok: true;
|
|
157
|
+
keys: string[];
|
|
158
|
+
} | {
|
|
159
|
+
ok: false;
|
|
160
|
+
stderr: string;
|
|
161
|
+
}): RemoteKeychainWriteVerification;
|
|
162
|
+
/**
|
|
163
|
+
* The actionable error message for a failed keychain-over-SSH push verification.
|
|
164
|
+
* Names the cause (locked remote login keychain) and steers to the two real fixes:
|
|
165
|
+
* re-run with the headless-readable file backend, or unlock the remote keychain.
|
|
166
|
+
* Pure + exported so the exact guidance is asserted in tests.
|
|
167
|
+
*/
|
|
168
|
+
export declare function keychainWriteFailureMessage(host: string, bundle: string, reason: string): string;
|
|
169
|
+
/**
|
|
170
|
+
* Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
|
|
171
|
+
* than prompting Touch ID) and confirm the pushed keys materialized. Drives the
|
|
172
|
+
* remote's own `secrets export <bundle> --plaintext --format json` — the same read
|
|
173
|
+
* a headless release performs — but keeps only the KEY NAMES; the plaintext values
|
|
174
|
+
* are dropped immediately and never retained or logged. Returns a verification
|
|
175
|
+
* verdict; the caller renders `keychainWriteFailureMessage` on failure.
|
|
176
|
+
*/
|
|
177
|
+
export declare function verifyRemoteKeychainPush(target: string, bundle: string, pushedKeys: string[], opts?: {
|
|
178
|
+
osLookupName?: string;
|
|
179
|
+
}): RemoteKeychainWriteVerification;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
|
-
import {
|
|
20
|
+
import { emitSecretAudit } from './audit.js';
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
22
22
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
23
23
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
@@ -74,8 +74,11 @@ export async function resolveHostSshTarget(nameOrAlias) {
|
|
|
74
74
|
return nameOrAlias;
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* Merge `--host <single>`
|
|
78
|
-
* list.
|
|
77
|
+
* Merge `--host <single>` / `--hosts <a,b,c>` (and their `--device` / `--devices`
|
|
78
|
+
* aliases) into an ordered, de-duplicated list. All four flags compose; any alone
|
|
79
|
+
* works. `--device`/`--devices` resolve identically to `--host`/`--hosts` so the
|
|
80
|
+
* fleet-wide `--device` vocabulary (see `agents activity`, `agents run --device`)
|
|
81
|
+
* works on the secrets remote commands too. Empty when none is set.
|
|
79
82
|
*/
|
|
80
83
|
export function parseHostsOption(opts) {
|
|
81
84
|
const out = [];
|
|
@@ -89,9 +92,14 @@ export function parseHostsOption(opts) {
|
|
|
89
92
|
};
|
|
90
93
|
if (opts.host)
|
|
91
94
|
push(opts.host);
|
|
95
|
+
if (opts.device)
|
|
96
|
+
push(opts.device);
|
|
92
97
|
if (opts.hosts)
|
|
93
98
|
for (const h of opts.hosts.split(','))
|
|
94
99
|
push(h);
|
|
100
|
+
if (opts.devices)
|
|
101
|
+
for (const h of opts.devices.split(','))
|
|
102
|
+
push(h);
|
|
95
103
|
return out;
|
|
96
104
|
}
|
|
97
105
|
/**
|
|
@@ -203,8 +211,8 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
|
203
211
|
// event on the INITIATING host too (values were pulled into this process and
|
|
204
212
|
// injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
|
|
205
213
|
// Values never enter the payload — only the bundle, target host, and count.
|
|
206
|
-
|
|
207
|
-
|
|
214
|
+
emitSecretAudit({
|
|
215
|
+
event: 'secrets.get',
|
|
208
216
|
bundle,
|
|
209
217
|
operation: 'remote resolve',
|
|
210
218
|
source: 'remote',
|
|
@@ -214,3 +222,129 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
|
214
222
|
});
|
|
215
223
|
return env;
|
|
216
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* The remote's headless read-back raises one of these when a keychain-backed bundle
|
|
227
|
+
* has metadata but no readable value items — the exact locked-login-keychain
|
|
228
|
+
* signature. Anything else (connection refused, timeout, host key error) is a
|
|
229
|
+
* transient/unrelated failure and must NOT be mislabeled as a locked keychain.
|
|
230
|
+
*/
|
|
231
|
+
function isLockedKeychainReadBackError(stderr) {
|
|
232
|
+
const s = stderr.toLowerCase();
|
|
233
|
+
return (
|
|
234
|
+
// bundles.ts agentOnly guard: "…is not unlocked in the secrets agent…"
|
|
235
|
+
s.includes('not unlocked') ||
|
|
236
|
+
s.includes('secrets agent') ||
|
|
237
|
+
// resolveBundleEnv: "Bundle '<b>' key '<k>': stored item '<item>' not found."
|
|
238
|
+
s.includes('stored item') ||
|
|
239
|
+
s.includes('not found'));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Decide whether a keychain-backed push to a remote actually PERSISTED its secret
|
|
243
|
+
* value items, given the read-back of that bundle from the remote's own store.
|
|
244
|
+
*
|
|
245
|
+
* The silent-failure this guards: pushing `--remote-backend keychain` (default) to
|
|
246
|
+
* a macOS host over headless SSH lands the bundle METADATA but not readable value
|
|
247
|
+
* items — the remote login keychain is locked in the non-interactive SSH context,
|
|
248
|
+
* so Security accepts the item WRITE at the DB level but the biometry-ACL'd item is
|
|
249
|
+
* unreadable, and the remote `import` still reports success (values written first,
|
|
250
|
+
* metadata `noAcl` last — bundles.ts writeBundleWithItems). The metadata-only bundle
|
|
251
|
+
* then fails every later read with the confusing `Bundle '<b>' key '<k>': stored
|
|
252
|
+
* item '<item>' not found` (bundles.ts resolveBundleEnv). We catch it by reading
|
|
253
|
+
* the bundle back the same way a release will (`secrets export --plaintext --format
|
|
254
|
+
* json`, driven headlessly on the remote so its `agentOnly` guard FAILS FAST before
|
|
255
|
+
* any keychain read — no Touch ID prompt) and confirming every pushed key returned.
|
|
256
|
+
*
|
|
257
|
+
* Pure so both branches are unit-testable without a real locked keychain: inject the
|
|
258
|
+
* "read-back failed / key absent" condition through `readBack`.
|
|
259
|
+
*/
|
|
260
|
+
export function evaluateKeychainWriteVerification(pushedKeys, readBack) {
|
|
261
|
+
if (!readBack.ok) {
|
|
262
|
+
const stderr = readBack.stderr.trim();
|
|
263
|
+
if (isLockedKeychainReadBackError(stderr)) {
|
|
264
|
+
// The remote's own headless read raised the not-unlocked / not-found signal —
|
|
265
|
+
// exactly the confusing error the user hits later. Surface it now, at push
|
|
266
|
+
// time, with the fix.
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
kind: 'locked-keychain',
|
|
270
|
+
reason: `the remote could not read it back${stderr ? ` (${stderr})` : ''}`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// A transient / unrelated failure (flaky SSH, timeout, bad payload). Re-surface
|
|
274
|
+
// verbatim — do NOT diagnose a locked keychain from a connection error.
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
kind: 'error',
|
|
278
|
+
reason: stderr || 'read-back failed',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const present = new Set(readBack.keys);
|
|
282
|
+
const missing = pushedKeys.filter((k) => !present.has(k));
|
|
283
|
+
if (missing.length > 0) {
|
|
284
|
+
// Read-back succeeded but some pushed keys are absent — the value items didn't
|
|
285
|
+
// persist. Same locked-keychain cause and fix.
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
kind: 'locked-keychain',
|
|
289
|
+
reason: `${missing.length} of ${pushedKeys.length} key(s) did not persist on the remote ` +
|
|
290
|
+
`(missing: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''})`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return { ok: true };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The actionable error message for a failed keychain-over-SSH push verification.
|
|
297
|
+
* Names the cause (locked remote login keychain) and steers to the two real fixes:
|
|
298
|
+
* re-run with the headless-readable file backend, or unlock the remote keychain.
|
|
299
|
+
* Pure + exported so the exact guidance is asserted in tests.
|
|
300
|
+
*/
|
|
301
|
+
export function keychainWriteFailureMessage(host, bundle, reason) {
|
|
302
|
+
return (`${host}: pushed '${bundle}' but the keychain items did not persist — ${reason}. ` +
|
|
303
|
+
`A macOS login keychain is LOCKED under headless SSH, so a keychain-backed write ` +
|
|
304
|
+
`lands the bundle metadata but no readable secret items, and later reads fail with ` +
|
|
305
|
+
`"stored item '…' not found". Re-run with a headless-readable backend:\n` +
|
|
306
|
+
` agents secrets export ${bundle} --host ${host} --remote-backend file\n` +
|
|
307
|
+
`(needs AGENTS_SECRETS_PASSPHRASE set locally), or unlock the remote keychain first ` +
|
|
308
|
+
`(e.g. an interactive login / \`agents secrets unlock\` on ${host}) and retry.`);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Read a bundle back from a remote over SSH (headlessly, so it fails fast rather
|
|
312
|
+
* than prompting Touch ID) and confirm the pushed keys materialized. Drives the
|
|
313
|
+
* remote's own `secrets export <bundle> --plaintext --format json` — the same read
|
|
314
|
+
* a headless release performs — but keeps only the KEY NAMES; the plaintext values
|
|
315
|
+
* are dropped immediately and never retained or logged. Returns a verification
|
|
316
|
+
* verdict; the caller renders `keychainWriteFailureMessage` on failure.
|
|
317
|
+
*/
|
|
318
|
+
export function verifyRemoteKeychainPush(target, bundle, pushedKeys, opts = {}) {
|
|
319
|
+
const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target, opts.osLookupName));
|
|
320
|
+
const res = sshExec(target, remoteCmd, { timeoutMs: REMOTE_TIMEOUT_MS });
|
|
321
|
+
if (res.code !== 0) {
|
|
322
|
+
const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
|
|
323
|
+
const stderr = `${why}${(res.stderr || res.stdout || '').trim() ? `: ${(res.stderr || res.stdout).trim()}` : ''}`;
|
|
324
|
+
return evaluateKeychainWriteVerification(pushedKeys, { ok: false, stderr });
|
|
325
|
+
}
|
|
326
|
+
// Take the outer { … } object (tolerate login-shell banner noise), read the key
|
|
327
|
+
// names, and immediately discard the values — we only need presence here.
|
|
328
|
+
const raw = res.stdout;
|
|
329
|
+
const start = raw.indexOf('{');
|
|
330
|
+
const end = raw.lastIndexOf('}');
|
|
331
|
+
const jsonText = start >= 0 && end >= start ? raw.slice(start, end + 1) : raw.trim();
|
|
332
|
+
let keys;
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(jsonText);
|
|
335
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
336
|
+
return evaluateKeychainWriteVerification(pushedKeys, {
|
|
337
|
+
ok: false,
|
|
338
|
+
stderr: 'unexpected read-back payload',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
keys = Object.keys(parsed);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return evaluateKeychainWriteVerification(pushedKeys, {
|
|
345
|
+
ok: false,
|
|
346
|
+
stderr: 'could not parse read-back JSON',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return evaluateKeychainWriteVerification(pushedKeys, { ok: true, keys });
|
|
350
|
+
}
|