@phnx-labs/agents-cli 1.22.57 → 1.22.59
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 +294 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +39 -1
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/monitors.js +198 -23
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +40 -5
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -1
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-sync.d.ts +29 -1
- package/dist/lib/accounting/usage-sync.js +76 -2
- package/dist/lib/accounting/usage.js +7 -1
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +86 -45
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
- package/dist/lib/daemon/usage-sync-service.js +14 -8
- package/dist/lib/daemon-services.js +1 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +120 -9
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +43 -133
- package/dist/lib/installations/versions.js +94 -206
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +65 -0
- package/dist/lib/self-update.js +138 -0
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +51 -6
- package/dist/lib/session/db.js +266 -20
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -3
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +128 -6
- package/dist/lib/traces/sync.js +294 -35
- package/dist/lib/traces/worker-template.js +154 -1
- package/dist/lib/view-types.d.ts +12 -0
- package/package.json +2 -2
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The secrets-agent:
|
|
3
|
-
*
|
|
4
|
-
* their own prompt.
|
|
2
|
+
* The secrets-agent: an in-memory broker that holds resolved bundle env after one
|
|
3
|
+
* Touch ID unlock, so concurrent agents don't each prompt.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* is the ssh-agent answer: `agents secrets unlock <bundle>` decrypts the bundle
|
|
11
|
-
* once (one prompt), ships the resolved env here, and every later read returns
|
|
12
|
-
* from memory over a user-only Unix socket — no prompt.
|
|
13
|
-
*
|
|
14
|
-
* Security model (deliberate): while a bundle is unlocked, any same-user
|
|
15
|
-
* process that can reach the socket reads it silently. That's strictly the same
|
|
16
|
-
* trust boundary the keychain already concedes (docs/secrets.md: the ACL is
|
|
17
|
-
* user-presence, not code-identity — any same-user process can pop the prompt
|
|
18
|
-
* and read), minus the visible prompt. We bound it with: explicit per-bundle
|
|
19
|
-
* opt-in (nothing is held unless you `unlock` it), an absolute TTL (~7d), an
|
|
20
|
-
* auto-wipe on sleep / logout, and `agents secrets lock`. A bare screen-lock is
|
|
21
|
-
* NOT a wipe (the login password already gates it). Nothing ever touches disk.
|
|
22
|
-
*
|
|
23
|
-
* macOS only: Linux libsecret has no biometry prompt, so there's nothing to
|
|
24
|
-
* deduplicate — every entry point here no-ops off darwin.
|
|
5
|
+
* Security model: while unlocked, any same-user process reaching the socket can
|
|
6
|
+
* read it silently — the same trust boundary the keychain already concedes. We
|
|
7
|
+
* bound it with per-bundle opt-in, a TTL (~7d), auto-wipe on sleep/logout, and
|
|
8
|
+
* explicit lock. Nothing touches disk. Off-darwin the broker is unused.
|
|
25
9
|
*/
|
|
26
10
|
import * as net from 'net';
|
|
27
11
|
import * as fs from 'fs';
|
|
@@ -42,10 +26,7 @@ import { MAX_LEASE_MS, MIN_LEASE_MS } from './lease.js';
|
|
|
42
26
|
import { selectLeasedEnv } from './lease.js';
|
|
43
27
|
import { emitSecretAudit } from './audit.js';
|
|
44
28
|
import { isDaemonServiceEnabled } from '../daemon-services.js';
|
|
45
|
-
// Re-exported
|
|
46
|
-
// the scope vocabulary; the definitions live in the leaf module scope.ts because
|
|
47
|
-
// agent.ts and session-store.ts import each other and a cyclic `const` read can
|
|
48
|
-
// hit the ESM temporal dead zone at runtime.
|
|
29
|
+
// Re-exported from scope.ts to break the agent.ts ↔ session-store.ts import cycle.
|
|
49
30
|
export { GLOBAL_HARNESS, bundleScopeChain };
|
|
50
31
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
51
32
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
@@ -53,24 +34,16 @@ const PROTOCOL_VERSION = 3;
|
|
|
53
34
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
54
35
|
export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
|
|
55
36
|
/**
|
|
56
|
-
* Whether the secrets broker service is enabled in
|
|
57
|
-
* Off-darwin this is irrelevant (the broker is never used), but the helper is
|
|
58
|
-
* kept synchronous and safe everywhere so callers can fail loud without a prompt.
|
|
37
|
+
* Whether the secrets broker service is enabled in daemon service config.
|
|
59
38
|
*/
|
|
60
39
|
export function isSecretsBrokerEnabled() {
|
|
61
40
|
return isDaemonServiceEnabled('secrets-broker');
|
|
62
41
|
}
|
|
63
42
|
/**
|
|
64
43
|
* Reserved store-key prefix for the `secrets list` metadata snapshot cache.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* `secrets list` within the hold window read metadata without a Touch ID
|
|
69
|
-
* prompt. Keyed by the name-set hash so adding/removing/renaming a bundle
|
|
70
|
-
* changes the key and misses the cache automatically — no active invalidation.
|
|
71
|
-
* The '!' sentinel can never collide with a real bundle name
|
|
72
|
-
* (BUNDLE_NAME_PATTERN requires an alphanumeric first char) and is safe as
|
|
73
|
-
* spawnSync argv (unlike a NUL byte); `status` hides these entries.
|
|
44
|
+
* Keyed by a hash of the keychain name-set; adding/removing/renaming a bundle
|
|
45
|
+
* changes the key and invalidates passively. The '!' sentinel cannot collide
|
|
46
|
+
* with a real bundle name and is safe as argv.
|
|
74
47
|
*/
|
|
75
48
|
export const META_CACHE_PREFIX = '!meta:';
|
|
76
49
|
/** After the store goes empty (all bundles locked or expired) for this long,
|
|
@@ -92,21 +65,9 @@ function delay(ms) {
|
|
|
92
65
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
66
|
}
|
|
94
67
|
/**
|
|
95
|
-
* Timeouts for the
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* `SOCKET_*` bound the socket round-trip inside the spawned `__secrets-*`
|
|
99
|
-
* child, and carry over unchanged from the inline `node -e` programs these
|
|
100
|
-
* replaced. `SYNC_*` bound the parent's `spawnSync` and must additionally cover
|
|
101
|
-
* the child's boot, so each is its socket budget plus ~2s of headroom. A parent
|
|
102
|
-
* timeout that fired before the child's own timer would report "broker down"
|
|
103
|
-
* for a broker that is merely slow, which costs a Touch ID prompt — so the
|
|
104
|
-
* parent budget is deliberately the looser of the two.
|
|
105
|
-
*
|
|
106
|
-
* Boot cost, measured on the bun-compiled binary on an M-series Mac (median of
|
|
107
|
-
* 15, against a live broker): ~96ms via the index.ts intercept, ~165ms if the
|
|
108
|
-
* intercept is moved below the startup statements, ~44ms for the `node -e` this
|
|
109
|
-
* replaces. The intercept is what keeps the regression modest; see index.ts.
|
|
68
|
+
* Timeouts for the synchronous broker clients. Parent `spawnSync` budgets are
|
|
69
|
+
* looser than socket budgets so a slow child boot isn't misreported as "broker
|
|
70
|
+
* down" (which costs a Touch ID prompt).
|
|
110
71
|
*/
|
|
111
72
|
const SOCKET_GET_TIMEOUT_MS = 2000;
|
|
112
73
|
const SOCKET_PING_TIMEOUT_MS = 700;
|
|
@@ -114,20 +75,14 @@ const SOCKET_LOCK_TIMEOUT_MS = 2000;
|
|
|
114
75
|
const SYNC_GET_TIMEOUT_MS = 4000;
|
|
115
76
|
const SYNC_PING_TIMEOUT_MS = 2500;
|
|
116
77
|
const SYNC_LOCK_TIMEOUT_MS = 4000;
|
|
117
|
-
//
|
|
118
|
-
// without importing this
|
|
119
|
-
// agent.js. See sync-commands.ts for why a shared binding rather than matching
|
|
120
|
-
// literals: the drift it prevents is silent and costs a Touch ID prompt per read.
|
|
78
|
+
// Re-exported from sync-commands.ts so index.ts can share the same argv tokens
|
|
79
|
+
// without importing this module (would create a cycle/startup cost).
|
|
121
80
|
export { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
|
|
122
81
|
/**
|
|
123
|
-
* Decide whether a persistent broker should
|
|
124
|
-
* code
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* restart until the cache is idle (TTL-expired / slept) means an
|
|
128
|
-
* in-place `npm i -g` never wipes a hot cache — the new code is adopted at the
|
|
129
|
-
* next quiet moment instead. See #435: rapid repeated upgrades wiped a hot
|
|
130
|
-
* cache on every bump and produced a recurring Touch ID storm.
|
|
82
|
+
* Decide whether a persistent broker should exit so launchd relaunches it on
|
|
83
|
+
* freshly-installed code. Only when the store is empty: exiting with held
|
|
84
|
+
* bundles would force a re-prompt. Deferring protects against rapid upgrades
|
|
85
|
+
* wiping the hot cache (#435).
|
|
131
86
|
*/
|
|
132
87
|
export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion, onDiskVersion) {
|
|
133
88
|
if (!persistent)
|
|
@@ -139,34 +94,16 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
|
|
|
139
94
|
return onDiskVersion !== runningVersion;
|
|
140
95
|
}
|
|
141
96
|
/**
|
|
142
|
-
* Client-side twin
|
|
143
|
-
*
|
|
144
|
-
* on-disk version. Only while it holds NO real unlocks — tearing down a hot
|
|
145
|
-
* broker wipes every held bundle, so the next read of each one re-prompts for
|
|
146
|
-
* Touch ID. On a machine where installed versions churn (dev builds stamp a
|
|
147
|
-
* fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
|
|
148
|
-
* turn), an unguarded teardown produced a rolling Touch ID storm — the exact
|
|
149
|
-
* failure #435 fixed on the server side. A hot, protocol-compatible broker
|
|
150
|
-
* keeps serving; its own sweep adopts the new code at the next quiet moment.
|
|
97
|
+
* Client-side twin: may only tear down a version-skewed broker when it holds
|
|
98
|
+
* no real unlocks, otherwise every held bundle re-prompts (#435).
|
|
151
99
|
*/
|
|
152
100
|
export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
|
|
153
101
|
return realHeldBundles === 0;
|
|
154
102
|
}
|
|
155
103
|
/**
|
|
156
|
-
* Whether a
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* unlocks. teardownStaleBroker() recognizes only the standalone broker's
|
|
160
|
-
* pidPath() O_EXCL claim (the daemon writes ownerPath(), never pidPath()), so
|
|
161
|
-
* evicting a daemon-hosted broker unlinks its socket WITHOUT stopping the daemon;
|
|
162
|
-
* the daemon then keeps hostedBroker != null and shouldTakeOverBroker() refuses
|
|
163
|
-
* to re-host, orphaning its broker until the daemon restarts while every reader
|
|
164
|
-
* falls onto cold one-off brokers that re-prompt Touch ID — the storm. Deferring
|
|
165
|
-
* is safe: daemon code-version upgrades are handled by postinstall.js restarting
|
|
166
|
-
* it, and agentPing() already gated on PROTOCOL_VERSION, so a code-skewed daemon
|
|
167
|
-
* broker is still wire-compatible. Only when NO daemon owns the broker (churning
|
|
168
|
-
* dev installs with a dead/absent daemon — the case #435's client twin was built
|
|
169
|
-
* for) does the zero-held-bundles teardown apply, exactly as before.
|
|
104
|
+
* Whether a client may evict a version-skewed broker. A daemon-hosted broker
|
|
105
|
+
* must never be client-evicted: the daemon owns the socket via ownerPath(), so
|
|
106
|
+
* unlinking it would orphan the broker until daemon restart (#435).
|
|
170
107
|
*/
|
|
171
108
|
export function shouldClientEvictSkewedBroker(daemonRunning, realHeldBundles) {
|
|
172
109
|
if (daemonRunning)
|
|
@@ -177,11 +114,8 @@ function onDarwin() {
|
|
|
177
114
|
return process.platform === 'darwin';
|
|
178
115
|
}
|
|
179
116
|
/**
|
|
180
|
-
* Build the broker's in-memory store
|
|
181
|
-
*
|
|
182
|
-
* Expired sessions are dropped; `--durable` and default entries alike come back
|
|
183
|
-
* (SLEEP already pruned the non-durable ones from the keychain while the broker
|
|
184
|
-
* was alive). Empty off darwin or when nothing was held.
|
|
117
|
+
* Build the broker's in-memory store from durable sessions so unlocks survive
|
|
118
|
+
* restart/upgrade. Empty off darwin or when nothing was held.
|
|
185
119
|
*/
|
|
186
120
|
function rehydrateStore(now = Date.now()) {
|
|
187
121
|
const store = new Map();
|
|
@@ -191,9 +125,7 @@ function rehydrateStore(now = Date.now()) {
|
|
|
191
125
|
}
|
|
192
126
|
return store;
|
|
193
127
|
}
|
|
194
|
-
/** Broker runtime dir
|
|
195
|
-
* AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
|
|
196
|
-
* run a real broker on a temp socket without touching the user's real dir. */
|
|
128
|
+
/** Broker runtime dir (0700). Override with AGENTS_SECRETS_AGENT_DIR for tests. */
|
|
197
129
|
function agentDir() {
|
|
198
130
|
const dir = process.env.AGENTS_SECRETS_AGENT_DIR || path.join(getHelpersDir(), 'secrets-agent');
|
|
199
131
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -214,32 +146,23 @@ function pidPath() {
|
|
|
214
146
|
return path.join(agentDir(), 'agent.pid');
|
|
215
147
|
}
|
|
216
148
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* permission IS the authorization boundary. See isRequestAuthorized.
|
|
149
|
+
* Per-broker capability token path (0600 inside the 0700 agent dir). The file
|
|
150
|
+
* permission is the authorization boundary.
|
|
220
151
|
*/
|
|
221
152
|
function tokenPath() {
|
|
222
153
|
return path.join(agentDir(), 'agent.token');
|
|
223
154
|
}
|
|
224
155
|
/**
|
|
225
|
-
* Path of the
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* single-instance claim, and a standalone that loses the socket race stays alive
|
|
229
|
-
* and quiescent while holding it (so launchd's KeepAlive doesn't restart-loop
|
|
230
|
-
* it), ready to take over if the hosted broker stops. Overloading it as the
|
|
231
|
-
* socket-ownership record made the standalone see a live holder and exit
|
|
232
|
-
* immediately — the exact restart loop that guard exists to prevent. This file
|
|
233
|
-
* answers a different question: which pid is serving the socket right now.
|
|
156
|
+
* Path of the current socket owner's pid. NOT `agent.pid`, which is the
|
|
157
|
+
* standalone service's O_EXCL single-instance claim. Separating them prevents a
|
|
158
|
+
* standby standalone from seeing a live holder and exiting in a restart loop.
|
|
234
159
|
*/
|
|
235
160
|
function ownerPath() {
|
|
236
161
|
return path.join(agentDir(), 'agent.owner');
|
|
237
162
|
}
|
|
238
163
|
/**
|
|
239
|
-
* Read the current broker capability token
|
|
240
|
-
* read
|
|
241
|
-
* restart mints a new token, so a stale read simply fails authorization and the
|
|
242
|
-
* caller falls back to a direct keychain read (soft, never a hard error).
|
|
164
|
+
* Read the current broker capability token. A restart mints a new token, so a
|
|
165
|
+
* stale read fails soft and the caller falls back to a direct keychain read.
|
|
243
166
|
*/
|
|
244
167
|
export function readAgentToken() {
|
|
245
168
|
try {
|
|
@@ -250,9 +173,7 @@ export function readAgentToken() {
|
|
|
250
173
|
return null;
|
|
251
174
|
}
|
|
252
175
|
}
|
|
253
|
-
/** Mint
|
|
254
|
-
* process that actually binds the socket calls this, so a losing starter never
|
|
255
|
-
* clobbers the live owner's token. Returns the token for in-memory comparison. */
|
|
176
|
+
/** Mint and persist a fresh capability token (0600) at socket-bind time. */
|
|
256
177
|
function writeAgentToken() {
|
|
257
178
|
const token = randomBytes(32).toString('hex');
|
|
258
179
|
const fp = tokenPath();
|
|
@@ -264,15 +185,9 @@ function writeAgentToken() {
|
|
|
264
185
|
return token;
|
|
265
186
|
}
|
|
266
187
|
/**
|
|
267
|
-
* Argv for re-invoking
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* (`node dist/index.js …`) and a Bun standalone binary (run directly). The old
|
|
271
|
-
* hand-rolled `[process.execPath, process.argv[1], …]` broke on standalone builds:
|
|
272
|
-
* process.argv[1] is the bun virtual entry `/$bunfs/root/agents` (fs.existsSync
|
|
273
|
-
* reports it as present), so it was passed as an argv element and the broker died
|
|
274
|
-
* with `unknown command '/$bunfs/root/agents'` — the daemon-hosted broker never
|
|
275
|
-
* bound its socket and every unlock reported "Could not start the secrets broker".
|
|
188
|
+
* Argv for re-invoking this CLI with a hidden subcommand. Uses getCliLaunch so
|
|
189
|
+
* both JS-entry and Bun-standalone installs work; the old `process.argv[1]`
|
|
190
|
+
* approach broke on standalone builds with a virtual `/$bunfs/root/agents` path.
|
|
276
191
|
*/
|
|
277
192
|
function cliSpawn(sub) {
|
|
278
193
|
const { command, args } = getCliLaunch(sub);
|
|
@@ -281,19 +196,12 @@ function cliSpawn(sub) {
|
|
|
281
196
|
function brokerSpawn() {
|
|
282
197
|
return cliSpawn(['secrets', '_agent-run']);
|
|
283
198
|
}
|
|
284
|
-
// ─── Legacy standalone launchd service (retired, #416
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// machine couldn't starve an on-demand cold start. That role now belongs to the
|
|
288
|
-
// always-on daemon, which hosts the broker socket-first (#416 step 1) — one
|
|
289
|
-
// supervised backbone instead of a second service. The functions below no
|
|
290
|
-
// longer INSTALL the standalone service; they only DETECT and RETIRE a plist
|
|
291
|
-
// left by an older version so the daemon can take over the socket. The upgrade
|
|
292
|
-
// migration (postinstall) and `ensureAgentRunning` both drive the retire path.
|
|
199
|
+
// ─── Legacy standalone launchd service (retired, #416) ───────────────────────
|
|
200
|
+
// The broker is now hosted by the always-on daemon. The functions below only
|
|
201
|
+
// detect and retire a plist left by older versions so the daemon owns the socket.
|
|
293
202
|
const SERVICE_LABEL = 'com.phnx-labs.agents-secrets-agent';
|
|
294
|
-
// LaunchAgents dir is
|
|
295
|
-
//
|
|
296
|
-
// the real ~/Library/LaunchAgents), so retirement there is a pure file removal.
|
|
203
|
+
// LaunchAgents dir. A relocated test dir is not launchd-managed, so retirement
|
|
204
|
+
// there is pure file removal.
|
|
297
205
|
function launchAgentsDir() {
|
|
298
206
|
return process.env.AGENTS_SECRETS_LAUNCHAGENTS_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
299
207
|
}
|
|
@@ -305,11 +213,8 @@ export function secretsAgentServiceInstalled() {
|
|
|
305
213
|
return onDarwin() && fs.existsSync(servicePlistPath());
|
|
306
214
|
}
|
|
307
215
|
/**
|
|
308
|
-
* Retire the legacy standalone
|
|
309
|
-
*
|
|
310
|
-
* daemon owns the broker socket. Idempotent and best-effort — a no-op when no
|
|
311
|
-
* legacy plist is present. Does NOT wipe held bundles: the booted-out process's
|
|
312
|
-
* memory is gone anyway, and the daemon-hosted broker starts fresh.
|
|
216
|
+
* Retire the legacy standalone launchd service so the daemon owns the socket.
|
|
217
|
+
* Idempotent; does not wipe held bundles.
|
|
313
218
|
*/
|
|
314
219
|
export function retireLegacySecretsAgentService() {
|
|
315
220
|
if (!onDarwin() || !secretsAgentServiceInstalled())
|
|
@@ -342,11 +247,9 @@ export function retireLegacySecretsAgentService() {
|
|
|
342
247
|
catch { /* already gone */ }
|
|
343
248
|
}
|
|
344
249
|
/**
|
|
345
|
-
* Stop the persistent broker for `agents secrets stop`: wipe
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* the always-on backbone, and stopping it would take down unrelated background
|
|
349
|
-
* work (routines, browser IPC, session-sync).
|
|
250
|
+
* Stop the persistent broker for `agents secrets stop`: wipe held bundles, then
|
|
251
|
+
* retire any legacy standalone service. The daemon-hosted broker itself is left
|
|
252
|
+
* running because it backs unrelated background work.
|
|
350
253
|
*/
|
|
351
254
|
export async function uninstallSecretsAgentService() {
|
|
352
255
|
if (!onDarwin())
|
|
@@ -356,17 +259,9 @@ export async function uninstallSecretsAgentService() {
|
|
|
356
259
|
}
|
|
357
260
|
// ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
|
|
358
261
|
/**
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
* Mutates `store` in place; returns the wire response.
|
|
363
|
-
*/
|
|
364
|
-
/**
|
|
365
|
-
* Count of real unlocked bundles in the store, excluding the internal
|
|
366
|
-
* `secrets list` metadata cache. Used to decide broker "warmth" for self-heal
|
|
367
|
-
* and idle-exit: a metadata-only store must read as empty so a disposable list
|
|
368
|
-
* cache never blocks an upgrade restart (#435) or an idle one-off broker from
|
|
369
|
-
* exiting. Pure + exported for unit testing.
|
|
262
|
+
* Count of real unlocked bundles, excluding the internal `secrets list` metadata
|
|
263
|
+
* cache. A metadata-only store must read as empty so it doesn't block upgrade
|
|
264
|
+
* self-heal or idle-exit (#435). Exported for tests.
|
|
370
265
|
*/
|
|
371
266
|
export function realBundleCount(store) {
|
|
372
267
|
let n = 0;
|
|
@@ -378,22 +273,17 @@ export function realBundleCount(store) {
|
|
|
378
273
|
export function scopedBundleKey(name, harness) {
|
|
379
274
|
return `${harness}:${name}`;
|
|
380
275
|
}
|
|
276
|
+
/** Pure request handler over the in-memory store. Exported for tests. */
|
|
381
277
|
export function handleAgentRequest(store, req, now = Date.now(),
|
|
382
|
-
// Eviction tombstones:
|
|
383
|
-
//
|
|
384
|
-
// mutating write) is rejected instead of re-populating the store with a
|
|
385
|
-
// pre-mutation copy. Broker-lifetime state, owned by the caller like `store`.
|
|
278
|
+
// Eviction tombstones: reject loads whose snapshot predates the eviction so a
|
|
279
|
+
// detached auto-load can't re-populate stale state.
|
|
386
280
|
evictedAt = new Map()) {
|
|
387
281
|
switch (req.cmd) {
|
|
388
282
|
case 'ping':
|
|
389
|
-
// Report the version
|
|
390
|
-
// caches the value from the broker's startup), not the on-disk version.
|
|
391
|
-
// A client compares this to its own fresh on-disk read; a mismatch means
|
|
392
|
-
// the broker is running pre-upgrade code and should be restarted.
|
|
283
|
+
// Report the running version, not on-disk, so clients detect pre-upgrade brokers.
|
|
393
284
|
return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
|
|
394
285
|
case 'get': {
|
|
395
|
-
//
|
|
396
|
-
// an unscoped unlock serves every harness (bundleScopeChain).
|
|
286
|
+
// own-harness → global: scoped grants win, unscoped unlocks serve all.
|
|
397
287
|
for (const scope of bundleScopeChain(req.harness)) {
|
|
398
288
|
const key = scopedBundleKey(req.name, scope);
|
|
399
289
|
const e = store.get(key);
|
|
@@ -414,9 +304,7 @@ evictedAt = new Map()) {
|
|
|
414
304
|
case 'load': {
|
|
415
305
|
const evictedTs = evictedAt.get(req.name);
|
|
416
306
|
if (evictedTs !== undefined && req.snapshotAt !== undefined && req.snapshotAt <= evictedTs) {
|
|
417
|
-
//
|
|
418
|
-
// this name; accepting it would serve (and later re-persist) stale
|
|
419
|
-
// state. A load with no snapshotAt (older client) is accepted as before.
|
|
307
|
+
// Reject loads captured before a mutating write evicted this name.
|
|
420
308
|
return { ok: false, error: `stale load for '${req.name}': snapshot predates an eviction` };
|
|
421
309
|
}
|
|
422
310
|
const harness = req.harness || GLOBAL_HARNESS;
|
|
@@ -468,28 +356,18 @@ evictedAt = new Map()) {
|
|
|
468
356
|
}
|
|
469
357
|
}
|
|
470
358
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* password, and with the ~7d hold, re-authing after every lock would defeat the
|
|
475
|
-
* point. Logout needs no line — it tears down the launchd session and kills the
|
|
476
|
-
* broker outright. Pure + exported so the LOCK-survives / SLEEP-wipes contract
|
|
477
|
-
* has direct regression coverage (the inline stdout handler isn't unit-testable).
|
|
359
|
+
* Wipe the in-memory store only on SLEEP, not screen-lock (already gated by the
|
|
360
|
+
* login password). Exported for regression coverage of the LOCK-survives /
|
|
361
|
+
* SLEEP-wipes contract.
|
|
478
362
|
*/
|
|
479
363
|
export function shouldWipeOnWatchEvent(chunk) {
|
|
480
364
|
return /\bSLEEP\b/.test(chunk);
|
|
481
365
|
}
|
|
482
366
|
/**
|
|
483
|
-
* Authorization gate
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* 0700 agent dir and so is readable only by the UID that owns the broker.
|
|
488
|
-
*
|
|
489
|
-
* Fail closed: a missing/empty expected token (no token file) rejects every
|
|
490
|
-
* command but ping. `ping` stays unauthenticated — it exposes only the protocol
|
|
491
|
-
* and cli version, and clients need it to detect a reachable broker before they
|
|
492
|
-
* have any reason to read the token. Pure + exported for direct unit testing.
|
|
367
|
+
* Authorization gate: every request except `ping` must carry the per-broker
|
|
368
|
+
* capability token from the 0600 token file. `ping` stays unauthenticated so
|
|
369
|
+
* clients can probe reachability before reading the token. Fail closed. Exported
|
|
370
|
+
* for tests.
|
|
493
371
|
*/
|
|
494
372
|
export function isRequestAuthorized(req, expectedToken) {
|
|
495
373
|
if (req.cmd === 'ping')
|
|
@@ -499,11 +377,9 @@ export function isRequestAuthorized(req, expectedToken) {
|
|
|
499
377
|
return req.token === expectedToken;
|
|
500
378
|
}
|
|
501
379
|
/**
|
|
502
|
-
* Build the socket `connection` handler shared by
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
* resolves the currently-expected capability token per request so a token
|
|
506
|
-
* rotation on broker restart is picked up without rebuilding the handler.
|
|
380
|
+
* Build the socket `connection` handler shared by standalone and daemon-hosted
|
|
381
|
+
* brokers. Newline-framed JSON in, one response line out, with per-request token
|
|
382
|
+
* lookup so rotation on restart is picked up.
|
|
507
383
|
*/
|
|
508
384
|
export function makeConnectionHandler(handle, token) {
|
|
509
385
|
return (conn) => {
|
|
@@ -534,19 +410,12 @@ export function makeConnectionHandler(handle, token) {
|
|
|
534
410
|
};
|
|
535
411
|
}
|
|
536
412
|
/**
|
|
537
|
-
* Whether a live process still owns the broker pid file.
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
* single-threaded, so a big `get` or the rehydrate at startup can blow the
|
|
541
|
-
* 700ms ping budget while the process is perfectly healthy. Treating that as
|
|
542
|
-
* death let a starting broker unlink a live socket and rebind, leaving the old
|
|
543
|
-
* process alive holding every unlocked bundle in RAM that no client could reach
|
|
544
|
-
* any more (observed live: two brokers, one socket path, two kernel sockets).
|
|
545
|
-
* The pid file is the second, independent liveness signal that makes the reclaim
|
|
413
|
+
* Whether a live process still owns the broker pid file. A single missed ping
|
|
414
|
+
* isn't proof of death: the single-threaded broker can blow the ping budget
|
|
415
|
+
* while healthy. The pid file is the second liveness signal that makes reclaim
|
|
546
416
|
* safe.
|
|
547
417
|
*/
|
|
548
|
-
/** Drop our ownership record
|
|
549
|
-
* successor that already claimed the socket. */
|
|
418
|
+
/** Drop our ownership record only if it is still ours. */
|
|
550
419
|
export function releaseBrokerPid() {
|
|
551
420
|
try {
|
|
552
421
|
if (parseInt(fs.readFileSync(ownerPath(), 'utf-8').trim(), 10) === process.pid) {
|
|
@@ -565,13 +434,8 @@ export function brokerPidAlive() {
|
|
|
565
434
|
}
|
|
566
435
|
}
|
|
567
436
|
/**
|
|
568
|
-
* Bind the shared broker socket without stealing it from
|
|
569
|
-
*
|
|
570
|
-
* either startup order is safe: a reachable owner wins, while an unreachable
|
|
571
|
-
* stale socket is reclaimed once.
|
|
572
|
-
*
|
|
573
|
-
* "Unreachable" is established with retries plus a pid-file liveness check, never
|
|
574
|
-
* a single ping — see {@link brokerPidAlive}.
|
|
437
|
+
* Bind the shared broker socket without stealing it from a live owner. Uses
|
|
438
|
+
* retries plus pid-file liveness, not a single ping, to decide "unreachable".
|
|
575
439
|
*/
|
|
576
440
|
async function bindBrokerSocket(sock, onConnection) {
|
|
577
441
|
const listenOnce = () => new Promise((resolve, reject) => {
|
|
@@ -588,20 +452,11 @@ async function bindBrokerSocket(sock, onConnection) {
|
|
|
588
452
|
fs.chmodSync(sock, 0o600);
|
|
589
453
|
}
|
|
590
454
|
catch { /* dir 0700 already gates it */ }
|
|
591
|
-
// Mint the
|
|
592
|
-
// confirmed socket owner — so a losing starter never clobbers the live
|
|
593
|
-
// owner's token (RUSH-1760).
|
|
455
|
+
// Mint the token and record ownership only once we are the confirmed owner.
|
|
594
456
|
try {
|
|
595
457
|
writeAgentToken();
|
|
596
458
|
}
|
|
597
459
|
catch { /* dir 0700 gates the socket regardless */ }
|
|
598
|
-
// Record ownership at the same moment, for the same reason. The
|
|
599
|
-
// daemon-hosted broker deliberately skips the O_EXCL pid-file GUARD (the
|
|
600
|
-
// daemon owns its instance), but without an ownership RECORD
|
|
601
|
-
// brokerPidAlive() has nothing to read — so a later starter saw an
|
|
602
|
-
// ownerless socket and reclaimed the live hosted broker anyway, which is
|
|
603
|
-
// the primary configuration and the one that actually orphaned here.
|
|
604
|
-
// Writing it from the confirmed owner covers both broker flavours.
|
|
605
460
|
try {
|
|
606
461
|
fs.writeFileSync(ownerPath(), String(process.pid));
|
|
607
462
|
}
|
|
@@ -612,17 +467,14 @@ async function bindBrokerSocket(sock, onConnection) {
|
|
|
612
467
|
let bound = await listenOnce();
|
|
613
468
|
if (bound !== 'inuse')
|
|
614
469
|
return bound;
|
|
615
|
-
// Give a busy owner several chances before concluding it is gone.
|
|
616
|
-
// ping used to be enough to evict a healthy broker and orphan its RAM.
|
|
470
|
+
// Give a busy owner several chances before concluding it is gone.
|
|
617
471
|
for (let attempt = 0; attempt < BIND_PROBE_ATTEMPTS; attempt++) {
|
|
618
472
|
if ((await agentPing()).reachable)
|
|
619
473
|
return null;
|
|
620
474
|
if (attempt < BIND_PROBE_ATTEMPTS - 1)
|
|
621
475
|
await delay(BIND_PROBE_INTERVAL_MS);
|
|
622
476
|
}
|
|
623
|
-
//
|
|
624
|
-
// that still owns the pid file, and let the caller surface the wedge instead
|
|
625
|
-
// of quietly starting a second broker on the same path.
|
|
477
|
+
// Refuse to steal from a process that still owns the pid file.
|
|
626
478
|
if (brokerPidAlive()) {
|
|
627
479
|
throw new Error(`Secrets broker socket is held by a live process that is not answering: ${sock}. ` +
|
|
628
480
|
`Run 'agents secrets lock --all' then retry, or stop the stuck broker.`);
|
|
@@ -639,19 +491,16 @@ async function bindBrokerSocket(sock, onConnection) {
|
|
|
639
491
|
throw new Error(`Secrets broker socket is in use but unreachable: ${sock}`);
|
|
640
492
|
}
|
|
641
493
|
/**
|
|
642
|
-
* Run the broker in the foreground. Spawned
|
|
643
|
-
* `agents secrets _agent-run`.
|
|
644
|
-
*
|
|
494
|
+
* Run the standalone broker in the foreground. Spawned by ensureAgentRunning via
|
|
495
|
+
* `agents secrets _agent-run`. Serves the socket, sweeps expired entries, wipes
|
|
496
|
+
* on sleep, and self-exits when idle.
|
|
645
497
|
*/
|
|
646
498
|
export async function runSecretsAgent(opts = {}) {
|
|
647
499
|
if (!onDarwin())
|
|
648
500
|
return null; // nothing to broker without biometry prompts
|
|
649
|
-
//
|
|
650
|
-
// exiting would just make launchd cold-start us again, reintroducing the
|
|
651
|
-
// startup-under-load fragility the service exists to avoid.
|
|
501
|
+
// Persistent launchd service must never idle-exit; launchd would cold-start again.
|
|
652
502
|
const persistent = opts.service === true;
|
|
653
|
-
//
|
|
654
|
-
// exit quietly — the existing one keeps serving.
|
|
503
|
+
// O_EXCL single-instance guard.
|
|
655
504
|
const pidFile = pidPath();
|
|
656
505
|
try {
|
|
657
506
|
const fd = fs.openSync(pidFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
|
@@ -675,8 +524,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
675
524
|
}
|
|
676
525
|
}
|
|
677
526
|
const store = rehydrateStore();
|
|
678
|
-
//
|
|
679
|
-
// the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
|
|
527
|
+
// Track when the store last held something so idle brokers exit.
|
|
680
528
|
let emptySince = Date.now();
|
|
681
529
|
const sock = socketPath();
|
|
682
530
|
const releasePid = () => {
|
|
@@ -687,9 +535,8 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
687
535
|
}
|
|
688
536
|
catch { /* gone or no longer ours */ }
|
|
689
537
|
};
|
|
690
|
-
// Register lifecycle handlers before socket arbitration
|
|
691
|
-
//
|
|
692
|
-
// kickstart/bootout during that wait must still release its pid-file lease.
|
|
538
|
+
// Register lifecycle handlers before socket arbitration so a standby service
|
|
539
|
+
// still releases its pid-file lease on kickstart/bootout.
|
|
693
540
|
let standbyTimer = null;
|
|
694
541
|
let cleanupActive = null;
|
|
695
542
|
let shuttingDown = false;
|
|
@@ -715,9 +562,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
715
562
|
};
|
|
716
563
|
process.on('SIGTERM', onSigterm);
|
|
717
564
|
process.on('SIGINT', onSigint);
|
|
718
|
-
// Capture the version
|
|
719
|
-
// an in-place upgrade has landed and self-heal onto it. getCliVersion caches
|
|
720
|
-
// this value for the process lifetime; getCliVersionFresh re-reads on disk.
|
|
565
|
+
// Capture the running version so the sweep can self-heal onto upgraded code.
|
|
721
566
|
const runningVersion = getCliVersion();
|
|
722
567
|
// "Warmth" for self-heal / idle-exit counts only real unlocked bundles, NOT
|
|
723
568
|
// the internal `secrets list` metadata cache (#524). Otherwise a 7d-TTL list
|
|
@@ -737,12 +582,10 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
737
582
|
}
|
|
738
583
|
}
|
|
739
584
|
const live = realBundleCount(store);
|
|
740
|
-
// Self-heal
|
|
741
|
-
// are held, so we never wipe live unlocks and force a re-prompt (#435). A
|
|
742
|
-
// metadata-only store still self-heals (the list cache is disposable).
|
|
585
|
+
// Self-heal only while no real unlocks are held (#435).
|
|
743
586
|
if (live === 0 &&
|
|
744
587
|
shouldSelfHealForUpgrade(persistent, live, runningVersion, getCliVersionFresh())) {
|
|
745
|
-
shutdown(0);
|
|
588
|
+
shutdown(0);
|
|
746
589
|
return;
|
|
747
590
|
}
|
|
748
591
|
if (live === 0) {
|
|
@@ -772,10 +615,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
772
615
|
throw err;
|
|
773
616
|
}
|
|
774
617
|
if (!server && persistent) {
|
|
775
|
-
//
|
|
776
|
-
// returned here. Stay quiescent instead, then claim the socket if the
|
|
777
|
-
// daemon-hosted owner goes away. The pid file keeps launchd/manual starts
|
|
778
|
-
// from creating additional waiters while this process is standing by.
|
|
618
|
+
// Stay quiescent rather than returning, or launchd KeepAlive would restart-loop.
|
|
779
619
|
do {
|
|
780
620
|
await new Promise((resolve) => {
|
|
781
621
|
standbyTimer = setTimeout(() => {
|
|
@@ -793,9 +633,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
793
633
|
}
|
|
794
634
|
let watcher = null;
|
|
795
635
|
let sweepTimer = null;
|
|
796
|
-
// Sync cleanup for
|
|
797
|
-
// server's 'close' event before exit, so fire-and-forget close + unlink.
|
|
798
|
-
// The public close() path below awaits with a bounded timeout (RUSH-2421).
|
|
636
|
+
// Sync cleanup for signal-driven exit: can't await 'close', so fire-and-forget.
|
|
799
637
|
cleanupActive = () => {
|
|
800
638
|
store.clear();
|
|
801
639
|
if (sweepTimer)
|
|
@@ -816,14 +654,8 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
816
654
|
releasePid();
|
|
817
655
|
};
|
|
818
656
|
sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
|
|
819
|
-
// Auto-lock on sleep.
|
|
820
|
-
//
|
|
821
|
-
// session and kills this in-memory broker). A bare screen-lock is deliberately
|
|
822
|
-
// NOT a wipe: with the ~7d hold, re-prompting after every lock would defeat the
|
|
823
|
-
// point, and a locked screen is already gated by the login password. If the
|
|
824
|
-
// installed helper predates watch-lock (exits non-zero immediately), we fall
|
|
825
|
-
// back to TTL-only and log nothing — the unlock already warned when
|
|
826
|
-
// lock_on_sleep couldn't be armed.
|
|
657
|
+
// Auto-lock on sleep. Wipe on SLEEP; screen-lock alone survives. If the helper
|
|
658
|
+
// predates watch-lock, fall back to TTL-only.
|
|
827
659
|
try {
|
|
828
660
|
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
829
661
|
watcher.stdout?.setEncoding('utf-8');
|
|
@@ -831,8 +663,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
831
663
|
if (shouldWipeOnWatchEvent(chunk)) {
|
|
832
664
|
store.clear();
|
|
833
665
|
emptySince = Date.now();
|
|
834
|
-
//
|
|
835
|
-
// unlock re-locks on sleep; `--durable` ones survive (session-store.ts).
|
|
666
|
+
// Default (non-durable) sessions re-lock on sleep; durable ones survive.
|
|
836
667
|
pruneSessionsOnSleep();
|
|
837
668
|
}
|
|
838
669
|
});
|
|
@@ -865,38 +696,23 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
865
696
|
};
|
|
866
697
|
}
|
|
867
698
|
/**
|
|
868
|
-
* Host the secrets broker inside the always-on daemon (#416).
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* keeps working through the versioned protocol — but it is daemon-safe:
|
|
873
|
-
*
|
|
874
|
-
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
875
|
-
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
876
|
-
* (those would kill the daemon — the daemon is the always-on backbone and
|
|
877
|
-
* manages its own version/lifecycle). The sweep only TTL-evicts.
|
|
878
|
-
*
|
|
879
|
-
* The caller (`runDaemon`) normally invokes this only when no broker answers
|
|
880
|
-
* its initial ping. Binding still arbitrates ownership through the same shared
|
|
881
|
-
* path as the standalone service: a live owner wins, while only an unreachable
|
|
882
|
-
* stale socket is reclaimed. Returns a handle the daemon closes on shutdown,
|
|
883
|
-
* or null off-darwin (nothing to broker without biometry).
|
|
699
|
+
* Host the secrets broker inside the always-on daemon (#416). Serves the same
|
|
700
|
+
* socket/protocol as the standalone broker, but daemon-safe: no pid-file guard,
|
|
701
|
+
* no process.exit/SIG handlers, no self-heal/idle-exit (the daemon owns the
|
|
702
|
+
* lifecycle). Returns null off-darwin.
|
|
884
703
|
*/
|
|
885
704
|
export async function startHostedBroker() {
|
|
886
705
|
if (!onDarwin())
|
|
887
|
-
return null;
|
|
706
|
+
return null; // nothing to broker without biometry prompts
|
|
888
707
|
const store = rehydrateStore();
|
|
889
|
-
const sock = socketPath();
|
|
708
|
+
const sock = socketPath();
|
|
890
709
|
const evictedAt = new Map();
|
|
891
710
|
const handle = (req) => handleAgentRequest(store, req, Date.now(), evictedAt);
|
|
892
711
|
const onConn = makeConnectionHandler(handle, readAgentToken);
|
|
893
712
|
const server = await bindBrokerSocket(sock, onConn);
|
|
894
713
|
if (!server)
|
|
895
714
|
return null;
|
|
896
|
-
// TTL eviction
|
|
897
|
-
// self-heal-exit or idle-exit here — the daemon is always-on and owns the
|
|
898
|
-
// upgrade/lifecycle path; a broker that called process.exit() would take the
|
|
899
|
-
// whole daemon down with it.
|
|
715
|
+
// TTL eviction only. The daemon is always-on and owns its own lifecycle.
|
|
900
716
|
const sweepTimer = setInterval(() => {
|
|
901
717
|
const now = Date.now();
|
|
902
718
|
for (const [name, e] of store) {
|
|
@@ -909,8 +725,7 @@ export async function startHostedBroker() {
|
|
|
909
725
|
}
|
|
910
726
|
}
|
|
911
727
|
}, SWEEP_INTERVAL_MS);
|
|
912
|
-
// Auto-lock on sleep, same as the standalone broker
|
|
913
|
-
// LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
|
|
728
|
+
// Auto-lock on sleep, same as the standalone broker.
|
|
914
729
|
let watcher = null;
|
|
915
730
|
try {
|
|
916
731
|
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
@@ -918,7 +733,7 @@ export async function startHostedBroker() {
|
|
|
918
733
|
watcher.stdout?.on('data', (chunk) => {
|
|
919
734
|
if (shouldWipeOnWatchEvent(chunk)) {
|
|
920
735
|
store.clear();
|
|
921
|
-
pruneSessionsOnSleep();
|
|
736
|
+
pruneSessionsOnSleep();
|
|
922
737
|
}
|
|
923
738
|
});
|
|
924
739
|
watcher.on('error', () => { watcher = null; });
|
|
@@ -927,10 +742,6 @@ export async function startHostedBroker() {
|
|
|
927
742
|
watcher = null;
|
|
928
743
|
}
|
|
929
744
|
return {
|
|
930
|
-
// RUSH-2421: await net.Server's 'close' (bounded) so a caller relying on
|
|
931
|
-
// close() cannot proceed past a socket that is not actually released yet.
|
|
932
|
-
// The daemon may fire-and-forget this promise; tests and any awaiter get
|
|
933
|
-
// the real release boundary.
|
|
934
745
|
async close() {
|
|
935
746
|
store.clear();
|
|
936
747
|
clearInterval(sweepTimer);
|
|
@@ -950,10 +761,8 @@ export async function startHostedBroker() {
|
|
|
950
761
|
/** How long to wait for net.Server.close()'s 'close' event before giving up. */
|
|
951
762
|
const SERVER_CLOSE_TIMEOUT_MS = 2_000;
|
|
952
763
|
/**
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
* be held — a successor bind could race EADDRINUSE against a half-closed server
|
|
956
|
-
* (RUSH-2421). Pure side-effect helper; never throws.
|
|
764
|
+
* Wait for net.Server.close()'s 'close' event (or a bounded timeout) so a
|
|
765
|
+
* successor bind doesn't race a half-closed socket (RUSH-2421).
|
|
957
766
|
*/
|
|
958
767
|
export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS) {
|
|
959
768
|
return new Promise((resolve) => {
|
|
@@ -976,11 +785,9 @@ export function closeServerBounded(server, timeoutMs = SERVER_CLOSE_TIMEOUT_MS)
|
|
|
976
785
|
});
|
|
977
786
|
}
|
|
978
787
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
979
|
-
/** Open the socket, send one request, resolve the one response. Async path
|
|
980
|
-
* used by the unlock/lock/status commands, which already run in async actions. */
|
|
788
|
+
/** Open the socket, send one request, resolve the one response. Async path. */
|
|
981
789
|
function request(req, timeoutMs = 2000) {
|
|
982
|
-
// Attach the capability token to every command except ping
|
|
983
|
-
// RUSH-1760). ping stays tokenless so a client can probe reachability first.
|
|
790
|
+
// Attach the capability token to every command except ping.
|
|
984
791
|
const authedReq = req.cmd === 'ping' ? req : { ...req, token: readAgentToken() ?? undefined };
|
|
985
792
|
return new Promise((resolve) => {
|
|
986
793
|
const conn = net.createConnection(socketPath());
|
|
@@ -1015,50 +822,24 @@ function request(req, timeoutMs = 2000) {
|
|
|
1015
822
|
});
|
|
1016
823
|
});
|
|
1017
824
|
}
|
|
1018
|
-
/**
|
|
1019
|
-
* never-unlocked path stays a single stat. */
|
|
825
|
+
/** Cheap socket-existence check so the never-unlocked path stays a single stat. */
|
|
1020
826
|
export function agentSocketExists() {
|
|
1021
827
|
return onDarwin() && fs.existsSync(socketPath());
|
|
1022
828
|
}
|
|
1023
829
|
/**
|
|
1024
|
-
* Spawn one of the `__secrets-*` sync clients
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
* **bun-compiled Mach-O**, where `process.execPath` is the CLI binary itself —
|
|
1030
|
-
* so the spawn became `agents -e <program> …`, which commander rejects with
|
|
1031
|
-
* `error: unknown option '-e'` and a non-zero exit. Every sync client then took
|
|
1032
|
-
* its own failure path (`agentGetSync` → null, `agentReachableSync` → false,
|
|
1033
|
-
* `agentEvictSync` → no-op), which reads as "broker down" and falls through to
|
|
1034
|
-
* a real keychain read. Net effect on the standalone binary: the broker cache
|
|
1035
|
-
* was never hit, so the `daily` policy's one-prompt-per-7d never applied and
|
|
1036
|
-
* every bundle read re-popped Touch ID.
|
|
1037
|
-
*
|
|
1038
|
-
* Same defect class as the broker-launch bug fixed in 1.20.56 (see cliSpawn's
|
|
1039
|
-
* doc comment); these three sites were simply never converted. Routing them
|
|
1040
|
-
* through `getCliLaunch` fixes both install shapes at once and keeps a single
|
|
1041
|
-
* code path.
|
|
1042
|
-
*
|
|
1043
|
-
* The subcommands are top-level `__secrets-*` tokens intercepted in index.ts
|
|
1044
|
-
* BEFORE commander and before the CLI's startup work, exactly like
|
|
1045
|
-
* `__daemon-run` and `__vault-age-helper`. That is load-bearing, not cosmetic:
|
|
1046
|
-
* a normal command path runs `checkForUpdates()` and `spawnDetachedSync()` on
|
|
1047
|
-
* every invocation (index.ts), so registering these as ordinary hidden
|
|
1048
|
-
* subcommands would fire an update check and spawn a detached background sync
|
|
1049
|
-
* on every cache hit — turning the hot read path into a process fork storm.
|
|
830
|
+
* Spawn one of the `__secrets-*` sync clients via `getCliLaunch`. The old
|
|
831
|
+
* `process.execPath -e` pattern broke on the bun-compiled Mach-O binary
|
|
832
|
+
* (1.20.53); routing through getCliLaunch fixes both install shapes. The
|
|
833
|
+
* subcommands are intercepted in index.ts before commander/startup so a cache
|
|
834
|
+
* hit doesn't fork detached syncs or run update checks.
|
|
1050
835
|
*/
|
|
1051
836
|
export function syncClientLaunch(sub, agentsBin) {
|
|
1052
837
|
return agentsBin ? getCliLaunch(sub, agentsBin) : getCliLaunch(sub);
|
|
1053
838
|
}
|
|
1054
839
|
function syncClient(sub, timeout) {
|
|
1055
|
-
// Soft on every failure:
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
// throw on a broken install — a bunfs entry whose execPath is gone, or a
|
|
1059
|
-
// browser/computer shim with no sibling index.js. Without this catch those
|
|
1060
|
-
// turn a graceful fallback into a crash, and only on already-degraded
|
|
1061
|
-
// installs, which is the worst time for it.
|
|
840
|
+
// Soft on every failure: the fast path must fall through to the real keychain
|
|
841
|
+
// read. getCliLaunch can throw on broken installs; crashing there turns a
|
|
842
|
+
// graceful fallback into a failure at the worst time.
|
|
1062
843
|
try {
|
|
1063
844
|
const { command, args } = syncClientLaunch(sub);
|
|
1064
845
|
return spawnSync(command, args, { encoding: 'utf-8', timeout });
|
|
@@ -1068,9 +849,8 @@ function syncClient(sub, timeout) {
|
|
|
1068
849
|
}
|
|
1069
850
|
}
|
|
1070
851
|
/**
|
|
1071
|
-
* Synchronous read for the hot path. Returns
|
|
1072
|
-
*
|
|
1073
|
-
* (soft — caller falls through to the real keychain). macOS only.
|
|
852
|
+
* Synchronous read for the hot path. Returns null on any failure so the caller
|
|
853
|
+
* falls through to the real keychain. macOS only.
|
|
1074
854
|
*/
|
|
1075
855
|
export function agentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
1076
856
|
if (!isSecretsBrokerEnabled())
|
|
@@ -1091,17 +871,9 @@ export function agentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
|
1091
871
|
}
|
|
1092
872
|
}
|
|
1093
873
|
/**
|
|
1094
|
-
* Last non-empty line of a child's stdout — the payload line.
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
* only ever emit its own JSON. The replacement boots the real CLI, so anything
|
|
1098
|
-
* the CLI prints on the way up shares that stream. Today the known chatter (the
|
|
1099
|
-
* `~/.agents/ is N commits behind` notice) correctly goes to stderr, but a
|
|
1100
|
-
* single future stdout write anywhere in startup would make `JSON.parse` throw
|
|
1101
|
-
* and turn every cache hit back into a silent miss — i.e. quietly reintroduce
|
|
1102
|
-
* the Touch-ID-on-every-read bug this fix closes, with no failing test to catch
|
|
1103
|
-
* it. Anchoring on the last line makes the payload the terminator of the
|
|
1104
|
-
* stream rather than the whole of it, so preceding chatter is inert.
|
|
874
|
+
* Last non-empty line of a child's stdout — the payload line. Anchors on the
|
|
875
|
+
* terminator so any future CLI startup chatter on stdout doesn't break JSON.parse
|
|
876
|
+
* and silently turn cache hits into misses.
|
|
1105
877
|
*/
|
|
1106
878
|
export function lastLine(stdout) {
|
|
1107
879
|
const lines = stdout.split('\n');
|
|
@@ -1113,12 +885,9 @@ export function lastLine(stdout) {
|
|
|
1113
885
|
return '';
|
|
1114
886
|
}
|
|
1115
887
|
/**
|
|
1116
|
-
* Synchronous liveness check: is a broker actually
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
* OOM, version-skew teardown) must NOT drag a foreground read through the
|
|
1120
|
-
* worker's 20s cold-start budget. A stale socket refuses instantly, so this is
|
|
1121
|
-
* fast in both the alive and dead cases. macOS only.
|
|
888
|
+
* Synchronous liveness check: is a broker actually listening? Gates the
|
|
889
|
+
* synchronous warm path so a stale socket doesn't drag a foreground read
|
|
890
|
+
* through a cold-start. macOS only.
|
|
1122
891
|
*/
|
|
1123
892
|
export function agentReachableSync() {
|
|
1124
893
|
if (!onDarwin())
|
|
@@ -1129,12 +898,8 @@ export function agentReachableSync() {
|
|
|
1129
898
|
return r !== null && r.status === 0 && !r.error;
|
|
1130
899
|
}
|
|
1131
900
|
/**
|
|
1132
|
-
* Synchronously evict one bundle
|
|
1133
|
-
*
|
|
1134
|
-
* keeps serving the pre-write snapshot for up to the ~7d hold — the next read
|
|
1135
|
-
* re-resolves from the keychain (one prompt) and re-caches fresh values.
|
|
1136
|
-
* Best-effort: no broker, no socket, or any failure is a silent no-op.
|
|
1137
|
-
* macOS only.
|
|
901
|
+
* Synchronously evict one bundle after a mutating write so the broker doesn't
|
|
902
|
+
* serve a stale snapshot for the hold window. Best-effort silent no-op. macOS only.
|
|
1138
903
|
*/
|
|
1139
904
|
export function agentEvictSync(name) {
|
|
1140
905
|
if (!onDarwin())
|
|
@@ -1145,51 +910,28 @@ export function agentEvictSync(name) {
|
|
|
1145
910
|
syncClient([SYNC_LOCK_CMD, name], SYNC_LOCK_TIMEOUT_MS);
|
|
1146
911
|
}
|
|
1147
912
|
// ─── Top-level `__secrets-*` sync-client entrypoints ────────────────────────
|
|
1148
|
-
//
|
|
1149
|
-
|
|
1150
|
-
// programs these replaced: 0 = hit/alive, 3 = miss/down. Lock deviates twice:
|
|
1151
|
-
// it reports 0 even when no broker answers, and 3 for an empty name, which it
|
|
1152
|
-
// refuses rather than forwarding as a lock-ALL (see runAgentLockSync).
|
|
1153
|
-
// Dispatched from index.ts BEFORE commander and before the startup statements,
|
|
1154
|
-
// so a cache hit never runs checkForUpdates() or forks a detached sync.
|
|
1155
|
-
/** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
|
|
1156
|
-
* cache hit. Exit 0 = hit, 3 = miss or broker down. */
|
|
913
|
+
// Dispatched from index.ts before commander/startup so cache hits stay cheap.
|
|
914
|
+
/** Body of `__secrets-get <name>`. Exit 0 = hit, 3 = miss/down. */
|
|
1157
915
|
export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
1158
916
|
const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
|
|
1159
917
|
if (r?.ok === true && r.cmd === 'get' && r.hit) {
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
// Awaited on the write callback, not fire-and-forget: stdout is a pipe here
|
|
1164
|
-
// (the parent captures it), pipe writes are asynchronous, and the caller
|
|
1165
|
-
// exits the process the moment this resolves. An unflushed write would be
|
|
1166
|
-
// truncated on exit — the parent would see a partial or empty payload, treat
|
|
1167
|
-
// it as a miss, and fall through to a keychain read, silently costing the
|
|
1168
|
-
// Touch ID prompt this whole path exists to avoid.
|
|
918
|
+
// Terminate with a newline and await the write callback; an unflushed pipe
|
|
919
|
+
// write would be truncated on exit and cost a Touch ID prompt.
|
|
1169
920
|
const payload = JSON.stringify({ bundle: r.bundle, env: r.env, lease: r.lease }) + '\n';
|
|
1170
921
|
await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
|
|
1171
922
|
return 0;
|
|
1172
923
|
}
|
|
1173
924
|
return 3;
|
|
1174
925
|
}
|
|
1175
|
-
/** Body of `__secrets-ping`. Exit 0 =
|
|
1176
|
-
*
|
|
1177
|
-
* PROTOCOL_VERSION: this only decides whether the auto-cache may take the
|
|
1178
|
-
* synchronous warm path, and a version-skewed broker still answers reads. That
|
|
1179
|
-
* matches the inline program this replaced. */
|
|
926
|
+
/** Body of `__secrets-ping`. Exit 0 = listening broker, 3 = nothing there.
|
|
927
|
+
* Does not gate on PROTOCOL_VERSION so version-skewed brokers still answer reads. */
|
|
1180
928
|
export async function runAgentPingSync() {
|
|
1181
929
|
const r = await request({ cmd: 'ping' }, SOCKET_PING_TIMEOUT_MS);
|
|
1182
930
|
return r?.ok === true && r.cmd === 'ping' ? 0 : 3;
|
|
1183
931
|
}
|
|
1184
932
|
/** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
|
|
1185
|
-
* broker answers
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
* An empty name is refused rather than forwarded: the broker treats a nameless
|
|
1189
|
-
* lock as lock-ALL (handleAgentRequest), so a bare `__secrets-lock` would wipe
|
|
1190
|
-
* every held bundle and re-prompt Touch ID for each. That was unreachable while
|
|
1191
|
-
* this was an inline `node -e` string; as a top-level argv token it is one typo
|
|
1192
|
-
* away, and agentEvictSync only ever locks by name. */
|
|
933
|
+
* broker answers. An empty name is refused to avoid accidentally locking ALL
|
|
934
|
+
* bundles. */
|
|
1193
935
|
export async function runAgentLockSync(name) {
|
|
1194
936
|
if (!name)
|
|
1195
937
|
return 3;
|
|
@@ -1199,11 +941,9 @@ export async function runAgentLockSync(name) {
|
|
|
1199
941
|
// Key inside the cached entry's env that holds the JSON metadata snapshot.
|
|
1200
942
|
const META_SNAPSHOT_KEY = '__snapshot__';
|
|
1201
943
|
/**
|
|
1202
|
-
* Read the cached `secrets list` metadata snapshot for
|
|
1203
|
-
* name-set hash
|
|
1204
|
-
*
|
|
1205
|
-
* the cache key: a changed name-set (bundle added/removed/renamed) yields a
|
|
1206
|
-
* different key and therefore a clean miss, so the stale set is never served.
|
|
944
|
+
* Read the cached `secrets list` metadata snapshot for a keychain name-set hash.
|
|
945
|
+
* Reuses agentGetSync. The name-set hash is the cache key, so add/remove/rename
|
|
946
|
+
* yields a clean miss.
|
|
1207
947
|
*/
|
|
1208
948
|
export function agentGetMetaSync(nameSetHash) {
|
|
1209
949
|
if (!onDarwin())
|
|
@@ -1221,11 +961,9 @@ export function agentGetMetaSync(nameSetHash) {
|
|
|
1221
961
|
}
|
|
1222
962
|
}
|
|
1223
963
|
/**
|
|
1224
|
-
* Fire-and-forget: populate the broker with a
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
* reserved META_CACHE_PREFIX key; the snapshot travels over stdin to the
|
|
1228
|
-
* detached worker (never argv/disk), same as value caching. macOS only.
|
|
964
|
+
* Fire-and-forget: populate the broker with a metadata snapshot so the next
|
|
965
|
+
* `secrets list` within the hold window is prompt-free. Stored under a reserved
|
|
966
|
+
* META_CACHE_PREFIX key; snapshot travels over stdin. macOS only.
|
|
1229
967
|
*/
|
|
1230
968
|
export function agentAutoLoadMetaSync(nameSetHash, bundles, ttlMs) {
|
|
1231
969
|
if (!onDarwin())
|