@phnx-labs/agents-cli 1.20.74 → 1.20.77
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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -114,6 +114,32 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
|
|
|
114
114
|
*/
|
|
115
115
|
export declare function validateExpiresFutureDated(iso: string): void;
|
|
116
116
|
export declare function bundleExists(name: string): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Thrown by `readBundle` for the one state `readBundleIfDecryptable` may treat
|
|
119
|
+
* as "present but permanently unreadable": file-store ciphertext on disk that
|
|
120
|
+
* will not decrypt with the passphrase in effect (lost/rotated key or a tampered
|
|
121
|
+
* store). It is deliberately narrow — a bundle that is merely *locked for this
|
|
122
|
+
* run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
|
|
123
|
+
* not logged in) is recoverable and must not be collapsed into this.
|
|
124
|
+
*/
|
|
125
|
+
export declare class BundleUndecryptableError extends Error {
|
|
126
|
+
constructor(message: string);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Read a bundle, or return null when its metadata is present but genuinely
|
|
130
|
+
* cannot be decrypted — a lost or rotated file-store passphrase, or a tampered
|
|
131
|
+
* file store (signalled by `BundleUndecryptableError`).
|
|
132
|
+
*
|
|
133
|
+
* Deleting such a bundle is the only way out of that state, and deletion needs
|
|
134
|
+
* no plaintext, so it must not be gated behind a successful decrypt. Every other
|
|
135
|
+
* failure rethrows: a genuinely missing bundle ("not found"), and — critically —
|
|
136
|
+
* a bundle that is only *temporarily locked* for this run (headless macOS with no
|
|
137
|
+
* `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
|
|
138
|
+
* recoverable "set the env / log in" state into "unreadable, safe to delete"
|
|
139
|
+
* would let `secrets delete <name> --yes` silently destroy a perfectly healthy
|
|
140
|
+
* bundle from a cron/launchd run that merely forgot to export the passphrase.
|
|
141
|
+
*/
|
|
142
|
+
export declare function readBundleIfDecryptable(name: string): SecretsBundle | null;
|
|
117
143
|
export declare function readBundle(name: string): SecretsBundle;
|
|
118
144
|
/** The default prompt policy applied to bundles without an explicit per-bundle
|
|
119
145
|
* policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
|
|
@@ -147,6 +173,14 @@ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAg
|
|
|
147
173
|
export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
|
|
148
174
|
export declare function writeBundleWithItems(bundle: SecretsBundle, items: Map<string, string>, opts?: WriteBundleOptions): void;
|
|
149
175
|
export declare function deleteBundle(name: string): boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
|
|
178
|
+
* `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
|
|
179
|
+
* batch-read, so writing it back only flips the ACL (via the helper's
|
|
180
|
+
* delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
|
|
181
|
+
* extra keychain read is issued. Exported for tests. Returns the count healed.
|
|
182
|
+
*/
|
|
183
|
+
export declare function healKeychainBundleMetadata(metaJsonByName: Map<string, string>): number;
|
|
150
184
|
export declare function listBundles(): SecretsBundle[];
|
|
151
185
|
export interface BundleEntryInfo {
|
|
152
186
|
key: string;
|
|
@@ -164,6 +198,12 @@ export interface ResolveBundleOptions {
|
|
|
164
198
|
* about to read the bundle.
|
|
165
199
|
*/
|
|
166
200
|
caller?: string;
|
|
201
|
+
/** Harness type whose unlock may be reused (claude, codex, kimi, ...). */
|
|
202
|
+
agent?: string;
|
|
203
|
+
/** Human duration rendered in the Touch ID prompt. */
|
|
204
|
+
duration?: string;
|
|
205
|
+
/** Explicitly permit this agent request to raise interactive authentication. */
|
|
206
|
+
interactiveUnlock?: boolean;
|
|
167
207
|
/**
|
|
168
208
|
* Skip the secrets-agent fast-path and read straight from the keychain
|
|
169
209
|
* (popping Touch ID). Set by callers that must NOT serve a cached snapshot —
|
|
@@ -238,15 +278,19 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
|
|
|
238
278
|
* True when the current process is a background / non-interactive context that
|
|
239
279
|
* must NEVER raise a Keychain biometry prompt on the interactive user's screen —
|
|
240
280
|
* a prompt nobody is watching. Two signals, either sufficient:
|
|
241
|
-
* - `AGENTS_RUNTIME` is `headless` or `
|
|
242
|
-
*
|
|
243
|
-
*
|
|
281
|
+
* - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
|
|
282
|
+
* launch, interactive included, and inherited by everything spawned beneath
|
|
283
|
+
* one (set on the child env by `agents run --headless`, scheduled routines,
|
|
284
|
+
* teammates, and interactive runs — see exec.ts:430, runner.ts,
|
|
285
|
+
* teams/agents.ts).
|
|
244
286
|
* - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
|
|
245
287
|
* stdio is redirected to a log — e.g. a release script run in the
|
|
246
288
|
* background as `( ... ) >log 2>&1 </dev/null`).
|
|
247
289
|
* `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
|
|
248
|
-
* even in a non-TTY context. An
|
|
249
|
-
*
|
|
290
|
+
* even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
|
|
291
|
+
* PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
|
|
292
|
+
* prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
|
|
293
|
+
* broker-only — the agent, not the human, is the caller there.
|
|
250
294
|
*
|
|
251
295
|
* Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
|
|
252
296
|
* broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
|
|
@@ -259,7 +303,10 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
|
|
|
259
303
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
260
304
|
* the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
|
|
261
305
|
*/
|
|
262
|
-
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform
|
|
306
|
+
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
|
|
307
|
+
stdin?: boolean;
|
|
308
|
+
stdout?: boolean;
|
|
309
|
+
}): boolean;
|
|
263
310
|
/**
|
|
264
311
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
265
312
|
*
|
|
@@ -277,6 +324,7 @@ export declare function readAndResolveBundleEnv(name: string, opts?: ResolveBund
|
|
|
277
324
|
bundle: SecretsBundle;
|
|
278
325
|
env: Record<string, string>;
|
|
279
326
|
};
|
|
327
|
+
export declare function humanUnlockDuration(ms: number): string;
|
|
280
328
|
export declare function keychainRef(key: string): string;
|
|
281
329
|
/** Options for rotateBundleSecret. */
|
|
282
330
|
export interface RotateOptions {
|
|
@@ -28,7 +28,7 @@ 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 { readMeta } from '../state.js';
|
|
31
|
+
import { readMeta, getHelpersDir } from '../state.js';
|
|
32
32
|
import { assertNameActiveInResourceProfile, filterNamesForActiveResourceProfile } from '../resource-profiles.js';
|
|
33
33
|
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, secretsHoldMs } from './agent.js';
|
|
34
34
|
import { loadSession, deleteSession } from './session-store.js';
|
|
@@ -243,6 +243,44 @@ export function bundleExists(name) {
|
|
|
243
243
|
validateBundleName(name);
|
|
244
244
|
return itemStore(bundleBackend(name)).has(bundleMetaItem(name));
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Thrown by `readBundle` for the one state `readBundleIfDecryptable` may treat
|
|
248
|
+
* as "present but permanently unreadable": file-store ciphertext on disk that
|
|
249
|
+
* will not decrypt with the passphrase in effect (lost/rotated key or a tampered
|
|
250
|
+
* store). It is deliberately narrow — a bundle that is merely *locked for this
|
|
251
|
+
* run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
|
|
252
|
+
* not logged in) is recoverable and must not be collapsed into this.
|
|
253
|
+
*/
|
|
254
|
+
export class BundleUndecryptableError extends Error {
|
|
255
|
+
constructor(message) {
|
|
256
|
+
super(message);
|
|
257
|
+
this.name = 'BundleUndecryptableError';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Read a bundle, or return null when its metadata is present but genuinely
|
|
262
|
+
* cannot be decrypted — a lost or rotated file-store passphrase, or a tampered
|
|
263
|
+
* file store (signalled by `BundleUndecryptableError`).
|
|
264
|
+
*
|
|
265
|
+
* Deleting such a bundle is the only way out of that state, and deletion needs
|
|
266
|
+
* no plaintext, so it must not be gated behind a successful decrypt. Every other
|
|
267
|
+
* failure rethrows: a genuinely missing bundle ("not found"), and — critically —
|
|
268
|
+
* a bundle that is only *temporarily locked* for this run (headless macOS with no
|
|
269
|
+
* `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
|
|
270
|
+
* recoverable "set the env / log in" state into "unreadable, safe to delete"
|
|
271
|
+
* would let `secrets delete <name> --yes` silently destroy a perfectly healthy
|
|
272
|
+
* bundle from a cron/launchd run that merely forgot to export the passphrase.
|
|
273
|
+
*/
|
|
274
|
+
export function readBundleIfDecryptable(name) {
|
|
275
|
+
try {
|
|
276
|
+
return readBundle(name);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
if (err instanceof BundleUndecryptableError)
|
|
280
|
+
return null;
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
246
284
|
export function readBundle(name) {
|
|
247
285
|
validateBundleName(name);
|
|
248
286
|
const backend = bundleBackend(name);
|
|
@@ -258,7 +296,7 @@ export function readBundle(name) {
|
|
|
258
296
|
// A file-backed bundle whose metadata is on disk but fails to decrypt is a
|
|
259
297
|
// wrong-passphrase error, not a missing bundle — surface that clearly.
|
|
260
298
|
if (backend === 'file' && fileStore.has(bundleMetaItem(name))) {
|
|
261
|
-
throw new
|
|
299
|
+
throw new BundleUndecryptableError(`Bundle '${name}': failed to decrypt — wrong AGENTS_SECRETS_PASSPHRASE or tampered file store. (${err.message})`);
|
|
262
300
|
}
|
|
263
301
|
if (vaultExists() && !getVaultSession().loggedIn) {
|
|
264
302
|
throw new Error(`Synced secrets are locked. Run: agents login`);
|
|
@@ -429,18 +467,40 @@ function finishBundleWrite(bundle, opts) {
|
|
|
429
467
|
}
|
|
430
468
|
export function writeBundle(bundle, opts = {}) {
|
|
431
469
|
const prepared = prepareBundleWrite(bundle);
|
|
432
|
-
//
|
|
433
|
-
// `
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
470
|
+
// Bundle metadata (name, description, policy, var names + refs, and any
|
|
471
|
+
// non-sensitive `--value` literals) is stored WITHOUT the biometry ACL at
|
|
472
|
+
// EVERY tier. It is non-sensitive by contract — the real secret values live in
|
|
473
|
+
// separate agents-cli.secrets.* items that keep the bundle's policy ACL — so a
|
|
474
|
+
// no-ACL metadata item is what lets `secrets list` and crabbox's `agents
|
|
475
|
+
// devices list` enumerate bundles with no Touch ID prompt (RUSH-1759). On an
|
|
476
|
+
// un-updated pinned helper this write fails loudly (the no-ACL command is
|
|
477
|
+
// missing) rather than silently landing an ACL'd item.
|
|
478
|
+
itemStore(prepared.backend).set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
|
|
437
479
|
finishBundleWrite(bundle, opts);
|
|
438
480
|
}
|
|
439
481
|
export function writeBundleWithItems(bundle, items, opts = {}) {
|
|
440
482
|
const prepared = prepareBundleWrite(bundle);
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
483
|
+
const store = itemStore(prepared.backend);
|
|
484
|
+
if (prepared.backend === 'keychain') {
|
|
485
|
+
// Only the keychain backend has a biometry ACL. Secret VALUE items carry the
|
|
486
|
+
// bundle's policy ACL (`never` ⇒ no-ACL); the metadata item is ALWAYS no-ACL
|
|
487
|
+
// (see writeBundle). The two must NOT ride one batch flag — a single noAcl
|
|
488
|
+
// over both would either strip biometry off the real secrets or re-ACL the
|
|
489
|
+
// metadata. Write the values first, then the metadata last: bundle discovery
|
|
490
|
+
// keys on the metadata item's presence, so metadata-last means a partial
|
|
491
|
+
// write reads as "no bundle yet", never as a bundle with missing values.
|
|
492
|
+
if (items.size > 0) {
|
|
493
|
+
store.setBatch(new Map(items), { noAcl: bundle.policy === 'never' });
|
|
494
|
+
}
|
|
495
|
+
store.set(prepared.metadataItem, prepared.metadataJson, { noAcl: true });
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
// file / vault: no ACL concept (noAcl is ignored), so one batched write is
|
|
499
|
+
// both correct and cheaper — e.g. a single age re-encrypt for the vault.
|
|
500
|
+
const batch = new Map(items);
|
|
501
|
+
batch.set(prepared.metadataItem, prepared.metadataJson);
|
|
502
|
+
store.setBatch(batch, { noAcl: bundle.policy === 'never' });
|
|
503
|
+
}
|
|
444
504
|
finishBundleWrite(bundle, opts);
|
|
445
505
|
}
|
|
446
506
|
export function deleteBundle(name) {
|
|
@@ -503,6 +563,79 @@ function parseBundleMeta(nameHint, json, backend) {
|
|
|
503
563
|
}
|
|
504
564
|
return bundle;
|
|
505
565
|
}
|
|
566
|
+
// Sentinel marking the one-time RUSH-1759 metadata-ACL heal as done. Lives under
|
|
567
|
+
// the regenerable helpers dir (same tree as the secrets-agent runtime state), so
|
|
568
|
+
// a cache wipe just re-runs the heal — harmless, since it is idempotent.
|
|
569
|
+
const METADATA_NOACL_SENTINEL = 'bundles-metadata-noacl-healed';
|
|
570
|
+
function metadataNoAclSentinelPath() {
|
|
571
|
+
return path.join(getHelpersDir(), 'secrets-agent', METADATA_NOACL_SENTINEL);
|
|
572
|
+
}
|
|
573
|
+
function bundleMetadataAclHealed() {
|
|
574
|
+
try {
|
|
575
|
+
return fs.existsSync(metadataNoAclSentinelPath());
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function markBundleMetadataAclHealed() {
|
|
582
|
+
try {
|
|
583
|
+
const file = metadataNoAclSentinelPath();
|
|
584
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
585
|
+
fs.writeFileSync(file, '', 'utf8');
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// Best effort — a missing sentinel just means the (idempotent) heal re-runs
|
|
589
|
+
// on the next broker-miss listing.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
|
|
594
|
+
* `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
|
|
595
|
+
* batch-read, so writing it back only flips the ACL (via the helper's
|
|
596
|
+
* delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
|
|
597
|
+
* extra keychain read is issued. Exported for tests. Returns the count healed.
|
|
598
|
+
*/
|
|
599
|
+
export function healKeychainBundleMetadata(metaJsonByName) {
|
|
600
|
+
let healed = 0;
|
|
601
|
+
for (const [name, json] of metaJsonByName) {
|
|
602
|
+
try {
|
|
603
|
+
// Cleartext meta-item name → hashed by keychainStore.set (#316) to the same
|
|
604
|
+
// service the read enumerated, so this overwrites the existing item in
|
|
605
|
+
// place, no-ACL. A per-item failure (e.g. a pinned helper without
|
|
606
|
+
// set-no-acl) must not abort the rest.
|
|
607
|
+
keychainStore.set(bundleMetaItem(name), json, { noAcl: true });
|
|
608
|
+
healed++;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
/* keep healing the remaining items */
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return healed;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* One-time driver around healKeychainBundleMetadata (RUSH-1759). macOS + real
|
|
618
|
+
* keychain only — libsecret/CredMan have no biometry ACL to shed, and a test
|
|
619
|
+
* backend has no real keychain — and gated by a sentinel so it runs at most
|
|
620
|
+
* once. Best-effort: a heal failure never breaks bundle listing.
|
|
621
|
+
*/
|
|
622
|
+
function healKeychainBundleMetadataAclOnce(metaJsonByName) {
|
|
623
|
+
if (metaJsonByName.size === 0)
|
|
624
|
+
return;
|
|
625
|
+
if (process.platform !== 'darwin')
|
|
626
|
+
return;
|
|
627
|
+
if (isKeychainBackendOverridden())
|
|
628
|
+
return;
|
|
629
|
+
if (bundleMetadataAclHealed())
|
|
630
|
+
return;
|
|
631
|
+
try {
|
|
632
|
+
if (healKeychainBundleMetadata(metaJsonByName) > 0)
|
|
633
|
+
markBundleMetadataAclHealed();
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
/* never let a heal failure break `secrets list` */
|
|
637
|
+
}
|
|
638
|
+
}
|
|
506
639
|
export function listBundles() {
|
|
507
640
|
const out = [];
|
|
508
641
|
// Keychain-backed bundles: batch all metadata reads behind ONE Touch ID
|
|
@@ -558,6 +691,7 @@ export function listBundles() {
|
|
|
558
691
|
else {
|
|
559
692
|
const fetched = getKeychainTokens(keychainServices);
|
|
560
693
|
const keychainBundles = [];
|
|
694
|
+
const metaJsonByName = new Map();
|
|
561
695
|
for (const service of keychainServices) {
|
|
562
696
|
const json = fetched.get(service);
|
|
563
697
|
if (json === undefined)
|
|
@@ -566,8 +700,10 @@ export function listBundles() {
|
|
|
566
700
|
? service.slice(BUNDLE_META_PREFIX.length)
|
|
567
701
|
: undefined;
|
|
568
702
|
const bundle = parseBundleMeta(nameHint, json, 'keychain');
|
|
569
|
-
if (bundle)
|
|
703
|
+
if (bundle) {
|
|
570
704
|
keychainBundles.push(bundle);
|
|
705
|
+
metaJsonByName.set(bundle.name, json);
|
|
706
|
+
}
|
|
571
707
|
}
|
|
572
708
|
for (const bundle of keychainBundles)
|
|
573
709
|
out.push(bundle);
|
|
@@ -578,6 +714,13 @@ export function listBundles() {
|
|
|
578
714
|
if (useAgent && keychainBundles.length > 0) {
|
|
579
715
|
agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
|
|
580
716
|
}
|
|
717
|
+
// One-time RUSH-1759 heal: bundles written before the metadata-no-ACL
|
|
718
|
+
// change carry an ACL'd metadata item, so this fresh read (a broker miss)
|
|
719
|
+
// popped Touch ID just to enumerate them. Re-home each metadata item
|
|
720
|
+
// no-ACL now — reusing the JSON we just read, so the heal adds no extra
|
|
721
|
+
// prompt — and every later enumeration is silent. Runs at most once (a
|
|
722
|
+
// sentinel under the regenerable helpers dir).
|
|
723
|
+
healKeychainBundleMetadataAclOnce(metaJsonByName);
|
|
581
724
|
}
|
|
582
725
|
}
|
|
583
726
|
}
|
|
@@ -877,15 +1020,19 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
877
1020
|
* True when the current process is a background / non-interactive context that
|
|
878
1021
|
* must NEVER raise a Keychain biometry prompt on the interactive user's screen —
|
|
879
1022
|
* a prompt nobody is watching. Two signals, either sufficient:
|
|
880
|
-
* - `AGENTS_RUNTIME` is `headless` or `
|
|
881
|
-
*
|
|
882
|
-
*
|
|
1023
|
+
* - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
|
|
1024
|
+
* launch, interactive included, and inherited by everything spawned beneath
|
|
1025
|
+
* one (set on the child env by `agents run --headless`, scheduled routines,
|
|
1026
|
+
* teammates, and interactive runs — see exec.ts:430, runner.ts,
|
|
1027
|
+
* teams/agents.ts).
|
|
883
1028
|
* - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
|
|
884
1029
|
* stdio is redirected to a log — e.g. a release script run in the
|
|
885
1030
|
* background as `( ... ) >log 2>&1 </dev/null`).
|
|
886
1031
|
* `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
|
|
887
|
-
* even in a non-TTY context. An
|
|
888
|
-
*
|
|
1032
|
+
* even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
|
|
1033
|
+
* PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
|
|
1034
|
+
* prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
|
|
1035
|
+
* broker-only — the agent, not the human, is the caller there.
|
|
889
1036
|
*
|
|
890
1037
|
* Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
|
|
891
1038
|
* broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
|
|
@@ -898,7 +1045,11 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
898
1045
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
899
1046
|
* the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
|
|
900
1047
|
*/
|
|
901
|
-
export function isHeadlessSecretsContext(env = process.env, platform = process.platform
|
|
1048
|
+
export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
|
|
1049
|
+
// Injected so the TTY branch below is testable: it is the branch that decides a
|
|
1050
|
+
// plain human shell still prompts, which is this guard's entire safety argument,
|
|
1051
|
+
// and reading process.* directly made it unreachable from a test.
|
|
1052
|
+
tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
|
|
902
1053
|
if (platform !== 'darwin')
|
|
903
1054
|
return false; // no biometry prompt to suppress off-darwin
|
|
904
1055
|
const override = env.AGENTS_SECRETS_NO_PROMPT;
|
|
@@ -906,10 +1057,21 @@ export function isHeadlessSecretsContext(env = process.env, platform = process.p
|
|
|
906
1057
|
return true;
|
|
907
1058
|
if (override === '0')
|
|
908
1059
|
return false;
|
|
1060
|
+
// Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
|
|
1061
|
+
// `terminal` was missing, which made an agent terminal the one launch path
|
|
1062
|
+
// still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
|
|
1063
|
+
// interactive run (exec.ts:430), that fell through to the TTY check below, and
|
|
1064
|
+
// a TTY meant "a human is watching, so prompting is fine". It is not fine —
|
|
1065
|
+
// opening a terminal is not a request to authenticate, and a launch that needs
|
|
1066
|
+
// a locked bundle should say so and point at `agents secrets unlock`, not grab
|
|
1067
|
+
// the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
|
|
1068
|
+
// an agent, so `agents secrets export` run beneath one resolves broker-only too —
|
|
1069
|
+
// correctly: there the agent, not the human, is the caller. A plain shell carries
|
|
1070
|
+
// no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
|
|
909
1071
|
const runtime = env.AGENTS_RUNTIME;
|
|
910
|
-
if (runtime === 'headless' || runtime === 'teams')
|
|
1072
|
+
if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
|
|
911
1073
|
return true;
|
|
912
|
-
return !
|
|
1074
|
+
return !tty.stdin && !tty.stdout;
|
|
913
1075
|
}
|
|
914
1076
|
/**
|
|
915
1077
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
@@ -935,7 +1097,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
935
1097
|
// file-backed bundle has none to dedup. The never-unlocked path is a single
|
|
936
1098
|
// stat (agentSocketExists) so it costs nothing when the agent isn't running.
|
|
937
1099
|
if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
938
|
-
const
|
|
1100
|
+
const harness = opts.agent || process.env.AGENTS_AGENT_NAME || 'cli';
|
|
1101
|
+
const hit = agentGetSync(name, harness);
|
|
939
1102
|
if (hit) {
|
|
940
1103
|
// The agent stores the FULL bundle env. Apply the same subset filter and
|
|
941
1104
|
// expiry gate as the slow path — without this, `--secrets-keys X` would
|
|
@@ -959,13 +1122,13 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
959
1122
|
// Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
|
|
960
1123
|
// across restart — this fixes BOTH the interactive re-prompt and the headless
|
|
961
1124
|
// throw below (which now fires only when there is genuinely no session).
|
|
962
|
-
const session = loadSession(name);
|
|
1125
|
+
const session = loadSession(name, Date.now(), harness);
|
|
963
1126
|
if (session) {
|
|
964
1127
|
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
965
1128
|
stampLastUsed(filtered.bundle);
|
|
966
1129
|
// Re-warm the broker with the remaining TTL so later reads hit RAM and
|
|
967
1130
|
// `agents secrets status` is honest. Best-effort; no-ops off darwin.
|
|
968
|
-
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
|
|
1131
|
+
agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), harness);
|
|
969
1132
|
emit('secrets.get', {
|
|
970
1133
|
module: 'secrets',
|
|
971
1134
|
bundle: name,
|
|
@@ -977,15 +1140,30 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
977
1140
|
return filtered;
|
|
978
1141
|
}
|
|
979
1142
|
}
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1143
|
+
// Never/no-ACL bundles remain prompt-free regardless. No agent launch — harness,
|
|
1144
|
+
// teammate, routine, or the always-on daemon — may raise the sheet itself.
|
|
1145
|
+
// Explicit opt-in ONLY — a deliberate NARROWING of the agent-triggered approval
|
|
1146
|
+
// added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
|
|
1147
|
+
// sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
|
|
1148
|
+
// That default — true whenever an agent name was present — was the spec, not a
|
|
1149
|
+
// bug. It is unwanted: each keychain read runs in its own helper process, so the
|
|
1150
|
+
// biometric assertion never reuses and one agent launch meant one sheet per
|
|
1151
|
+
// bundle. `agentOnly` decides alone now; a human in a plain shell carries no
|
|
1152
|
+
// AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
|
|
1153
|
+
// guard never fires, and they still get their prompt. No caller passes this flag;
|
|
1154
|
+
// it remains the seam for a future unlock path that wants the sheet on purpose.
|
|
1155
|
+
const interactiveUnlock = opts.interactiveUnlock ?? false;
|
|
1156
|
+
if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
|
|
1157
|
+
let noAclBundle = false;
|
|
1158
|
+
try {
|
|
1159
|
+
noAclBundle = bundlePolicy(readBundle(name)) === 'never';
|
|
1160
|
+
}
|
|
1161
|
+
catch { /* fail closed */ }
|
|
1162
|
+
if (!noAclBundle) {
|
|
1163
|
+
throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent. ` +
|
|
1164
|
+
`Run 'agents secrets unlock ${name}' in a terminal first — an agent launch ` +
|
|
1165
|
+
`never raises a Touch ID sheet on its own.`);
|
|
1166
|
+
}
|
|
989
1167
|
}
|
|
990
1168
|
if (backend === 'file')
|
|
991
1169
|
assertFileBackendUsable(name);
|
|
@@ -1004,12 +1182,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1004
1182
|
const reason = opts.caller
|
|
1005
1183
|
? `read ${name} secrets (for ${opts.caller})`
|
|
1006
1184
|
: `read ${name} secrets`;
|
|
1007
|
-
void reason;
|
|
1008
1185
|
// secretItems are storage names as enumerated (opaque hashed names on macOS
|
|
1009
1186
|
// with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
|
|
1010
1187
|
// hashed inside getBatch. Deduped because the hashed enumeration spans the
|
|
1011
1188
|
// bundle's whole namespace.
|
|
1012
|
-
const fetched =
|
|
1189
|
+
const fetched = backend === 'keychain'
|
|
1190
|
+
? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
|
|
1191
|
+
agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
|
|
1192
|
+
bundle: name,
|
|
1193
|
+
reason: opts.caller ? `to ${opts.caller}` : reason,
|
|
1194
|
+
duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
|
|
1195
|
+
defaultPolicy: secretsDefaultPolicy(),
|
|
1196
|
+
forceDuration: Boolean(opts.duration),
|
|
1197
|
+
})
|
|
1198
|
+
: store.getBatch([...new Set([metaItem, ...secretItems])]);
|
|
1013
1199
|
const json = fetched.get(metaItem);
|
|
1014
1200
|
if (json === undefined) {
|
|
1015
1201
|
// For a file-backed bundle the metadata item is on disk (that's how
|
|
@@ -1137,7 +1323,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1137
1323
|
bundlePolicy(bundle) === 'daily' &&
|
|
1138
1324
|
secretsAgentAutoEnabled() &&
|
|
1139
1325
|
canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
|
|
1140
|
-
agentAutoLoadSync(name, bundle, env, secretsHoldMs());
|
|
1326
|
+
agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || 'cli');
|
|
1141
1327
|
}
|
|
1142
1328
|
return { bundle, env };
|
|
1143
1329
|
}
|
|
@@ -1146,6 +1332,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1146
1332
|
throw err;
|
|
1147
1333
|
}
|
|
1148
1334
|
}
|
|
1335
|
+
export function humanUnlockDuration(ms) {
|
|
1336
|
+
const days = Math.round(ms / (24 * 60 * 60 * 1000));
|
|
1337
|
+
if (days >= 1)
|
|
1338
|
+
return `${days} day${days === 1 ? '' : 's'}`;
|
|
1339
|
+
const hours = Math.round(ms / (60 * 60 * 1000));
|
|
1340
|
+
if (hours >= 1)
|
|
1341
|
+
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
|
1342
|
+
const minutes = Math.max(1, Math.round(ms / (60 * 1000)));
|
|
1343
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
1344
|
+
}
|
|
1149
1345
|
// Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
|
|
1150
1346
|
export function keychainRef(key) {
|
|
1151
1347
|
return `keychain:${key}`;
|
|
@@ -187,7 +187,21 @@ export declare function rekeyStatus(): {
|
|
|
187
187
|
* count is then meaningless. Only probeable once the key record exists. */
|
|
188
188
|
enumerationOk: boolean;
|
|
189
189
|
};
|
|
190
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Check if a keychain/keyring item exists. Never prompts for biometry.
|
|
192
|
+
*
|
|
193
|
+
* Throws when the item cannot be reached — on macOS, when the signed helper is
|
|
194
|
+
* unavailable. That is deliberate: this primitive gates destructive writes as
|
|
195
|
+
* well as reads. Through `bundleExists()` it guards the `--force` overwrite
|
|
196
|
+
* checks in `agents secrets create` (`../../commands/secrets.ts`), the
|
|
197
|
+
* bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
|
|
198
|
+
* (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
|
|
199
|
+
* (`../session/sync/provision.ts`). A false "absent" silently disarms every one
|
|
200
|
+
* of them, so an unreachable keychain must fail loudly rather than answer "no".
|
|
201
|
+
*
|
|
202
|
+
* Tests needing this path without a helper install a backend via
|
|
203
|
+
* `setKeychainBackendForTest()`, which short-circuits on the next line.
|
|
204
|
+
*/
|
|
191
205
|
export declare function hasKeychainToken(item: string): boolean;
|
|
192
206
|
/**
|
|
193
207
|
* Retrieve a secret value from the keychain/keyring. Throws if not found.
|
|
@@ -196,7 +210,16 @@ export declare function hasKeychainToken(item: string): boolean;
|
|
|
196
210
|
* call in the same process). For bundles, prefer getKeychainTokens() so a
|
|
197
211
|
* single biometric prompt covers every key in the batch.
|
|
198
212
|
*/
|
|
199
|
-
export
|
|
213
|
+
export interface KeychainReadContext {
|
|
214
|
+
agent?: string;
|
|
215
|
+
bundle?: string;
|
|
216
|
+
reason?: string;
|
|
217
|
+
duration?: string;
|
|
218
|
+
defaultPolicy?: 'daily' | 'always' | 'never';
|
|
219
|
+
forceDuration?: boolean;
|
|
220
|
+
}
|
|
221
|
+
export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
|
|
222
|
+
export declare function getKeychainToken(item: string, context?: KeychainReadContext): string;
|
|
200
223
|
/**
|
|
201
224
|
* Batch-read multiple keychain items behind a single Touch ID prompt. The
|
|
202
225
|
* macOS helper holds one LAContext for its whole process: the first protected
|
|
@@ -207,7 +230,7 @@ export declare function getKeychainToken(item: string): string;
|
|
|
207
230
|
* On Linux or when a test backend is installed, falls back to individual
|
|
208
231
|
* lookups — no biometric prompt path on those platforms.
|
|
209
232
|
*/
|
|
210
|
-
export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
233
|
+
export declare function getKeychainTokens(items: string[], context?: KeychainReadContext): Map<string, string>;
|
|
211
234
|
/** Store or update a secret value in the keychain/keyring. Device-local;
|
|
212
235
|
* biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
|
|
213
236
|
* item WITHOUT the biometry access control so later reads are fully silent — it
|
|
@@ -481,8 +481,12 @@ export function computeRekeyPlan(services, values, key) {
|
|
|
481
481
|
try {
|
|
482
482
|
const parsed = JSON.parse(value);
|
|
483
483
|
if (parsed && typeof parsed === 'object') {
|
|
484
|
-
|
|
485
|
-
|
|
484
|
+
// Bundle metadata is non-sensitive by contract and stored no-ACL at
|
|
485
|
+
// EVERY tier (matches writeBundle in bundles.ts), so `secrets list` /
|
|
486
|
+
// crabbox's `agents devices list` enumerate bundles with no Touch ID
|
|
487
|
+
// (RUSH-1759). Re-home metadata no-ACL regardless of the bundle's policy;
|
|
488
|
+
// the real secret values (second loop) still carry their per-bundle ACL.
|
|
489
|
+
noAcl = true;
|
|
486
490
|
payload = JSON.stringify({ ...parsed, name });
|
|
487
491
|
}
|
|
488
492
|
}
|
|
@@ -669,7 +673,21 @@ export function rekeyStatus() {
|
|
|
669
673
|
enumerationOk,
|
|
670
674
|
};
|
|
671
675
|
}
|
|
672
|
-
/**
|
|
676
|
+
/**
|
|
677
|
+
* Check if a keychain/keyring item exists. Never prompts for biometry.
|
|
678
|
+
*
|
|
679
|
+
* Throws when the item cannot be reached — on macOS, when the signed helper is
|
|
680
|
+
* unavailable. That is deliberate: this primitive gates destructive writes as
|
|
681
|
+
* well as reads. Through `bundleExists()` it guards the `--force` overwrite
|
|
682
|
+
* checks in `agents secrets create` (`../../commands/secrets.ts`), the
|
|
683
|
+
* bundle-rename purge (`./bundles.ts`), the pull-rollback bookkeeping
|
|
684
|
+
* (`./sync.ts`), and the reuse-never-overwrite rule for `R2_SYNC_ENC_KEY`
|
|
685
|
+
* (`../session/sync/provision.ts`). A false "absent" silently disarms every one
|
|
686
|
+
* of them, so an unreachable keychain must fail loudly rather than answer "no".
|
|
687
|
+
*
|
|
688
|
+
* Tests needing this path without a helper install a backend via
|
|
689
|
+
* `setKeychainBackendForTest()`, which short-circuits on the next line.
|
|
690
|
+
*/
|
|
673
691
|
export function hasKeychainToken(item) {
|
|
674
692
|
item = prepareServiceName(item);
|
|
675
693
|
if (backend)
|
|
@@ -689,14 +707,14 @@ export function hasKeychainToken(item) {
|
|
|
689
707
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
690
708
|
}).status === 0;
|
|
691
709
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
export function getKeychainToken(item) {
|
|
710
|
+
export function keychainOperationPrompt(context = {}) {
|
|
711
|
+
const agent = context.agent || 'Agents CLI';
|
|
712
|
+
const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
|
|
713
|
+
const duration = context.duration ? ` for ${context.duration}` : '';
|
|
714
|
+
const reason = context.reason ? ` ${context.reason}` : '';
|
|
715
|
+
return `${agent} is requesting to unlock${bundle}${duration}${reason}.`;
|
|
716
|
+
}
|
|
717
|
+
export function getKeychainToken(item, context = {}) {
|
|
700
718
|
// Errors keep the requested (human-readable) name; the storage name may be
|
|
701
719
|
// an opaque hash.
|
|
702
720
|
const requested = item;
|
|
@@ -721,6 +739,11 @@ export function getKeychainToken(item) {
|
|
|
721
739
|
}
|
|
722
740
|
const bin = getKeychainHelperPath();
|
|
723
741
|
const result = spawnSync(bin, ['get', item, os.userInfo().username], {
|
|
742
|
+
env: {
|
|
743
|
+
...process.env,
|
|
744
|
+
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
745
|
+
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
746
|
+
},
|
|
724
747
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
725
748
|
});
|
|
726
749
|
if (result.status === 1)
|
|
@@ -746,7 +769,7 @@ export function getKeychainToken(item) {
|
|
|
746
769
|
* On Linux or when a test backend is installed, falls back to individual
|
|
747
770
|
* lookups — no biometric prompt path on those platforms.
|
|
748
771
|
*/
|
|
749
|
-
export function getKeychainTokens(items) {
|
|
772
|
+
export function getKeychainTokens(items, context = {}) {
|
|
750
773
|
const result = new Map();
|
|
751
774
|
if (items.length === 0)
|
|
752
775
|
return result;
|
|
@@ -793,6 +816,13 @@ export function getKeychainTokens(items) {
|
|
|
793
816
|
}
|
|
794
817
|
const bin = getKeychainHelperPath();
|
|
795
818
|
const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
|
|
819
|
+
env: {
|
|
820
|
+
...process.env,
|
|
821
|
+
AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
|
|
822
|
+
AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
|
|
823
|
+
AGENTS_KEYCHAIN_DEFAULT_POLICY: context.defaultPolicy || 'daily',
|
|
824
|
+
AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
|
|
825
|
+
},
|
|
796
826
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
797
827
|
});
|
|
798
828
|
if (child.status === 4) {
|