@phnx-labs/agents-cli 1.20.76 → 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 +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -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 +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- 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 +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- 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 +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -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.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -1020,15 +1020,19 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
1020
1020
|
* True when the current process is a background / non-interactive context that
|
|
1021
1021
|
* must NEVER raise a Keychain biometry prompt on the interactive user's screen —
|
|
1022
1022
|
* a prompt nobody is watching. Two signals, either sufficient:
|
|
1023
|
-
* - `AGENTS_RUNTIME` is `headless` or `
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
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).
|
|
1026
1028
|
* - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
|
|
1027
1029
|
* stdio is redirected to a log — e.g. a release script run in the
|
|
1028
1030
|
* background as `( ... ) >log 2>&1 </dev/null`).
|
|
1029
1031
|
* `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
|
|
1030
|
-
* even in a non-TTY context. An
|
|
1031
|
-
*
|
|
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.
|
|
1032
1036
|
*
|
|
1033
1037
|
* Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
|
|
1034
1038
|
* broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
|
|
@@ -1041,7 +1045,11 @@ export function resolveBundleEnv(bundle, _opts = {}) {
|
|
|
1041
1045
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
1042
1046
|
* the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
|
|
1043
1047
|
*/
|
|
1044
|
-
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 }) {
|
|
1045
1053
|
if (platform !== 'darwin')
|
|
1046
1054
|
return false; // no biometry prompt to suppress off-darwin
|
|
1047
1055
|
const override = env.AGENTS_SECRETS_NO_PROMPT;
|
|
@@ -1049,10 +1057,21 @@ export function isHeadlessSecretsContext(env = process.env, platform = process.p
|
|
|
1049
1057
|
return true;
|
|
1050
1058
|
if (override === '0')
|
|
1051
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.
|
|
1052
1071
|
const runtime = env.AGENTS_RUNTIME;
|
|
1053
|
-
if (runtime === 'headless' || runtime === 'teams')
|
|
1072
|
+
if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
|
|
1054
1073
|
return true;
|
|
1055
|
-
return !
|
|
1074
|
+
return !tty.stdin && !tty.stdout;
|
|
1056
1075
|
}
|
|
1057
1076
|
/**
|
|
1058
1077
|
* Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
|
|
@@ -1078,7 +1097,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1078
1097
|
// file-backed bundle has none to dedup. The never-unlocked path is a single
|
|
1079
1098
|
// stat (agentSocketExists) so it costs nothing when the agent isn't running.
|
|
1080
1099
|
if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
|
|
1081
|
-
const
|
|
1100
|
+
const harness = opts.agent || process.env.AGENTS_AGENT_NAME || 'cli';
|
|
1101
|
+
const hit = agentGetSync(name, harness);
|
|
1082
1102
|
if (hit) {
|
|
1083
1103
|
// The agent stores the FULL bundle env. Apply the same subset filter and
|
|
1084
1104
|
// expiry gate as the slow path — without this, `--secrets-keys X` would
|
|
@@ -1102,13 +1122,13 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1102
1122
|
// Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
|
|
1103
1123
|
// across restart — this fixes BOTH the interactive re-prompt and the headless
|
|
1104
1124
|
// throw below (which now fires only when there is genuinely no session).
|
|
1105
|
-
const session = loadSession(name);
|
|
1125
|
+
const session = loadSession(name, Date.now(), harness);
|
|
1106
1126
|
if (session) {
|
|
1107
1127
|
const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
|
|
1108
1128
|
stampLastUsed(filtered.bundle);
|
|
1109
1129
|
// Re-warm the broker with the remaining TTL so later reads hit RAM and
|
|
1110
1130
|
// `agents secrets status` is honest. Best-effort; no-ops off darwin.
|
|
1111
|
-
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);
|
|
1112
1132
|
emit('secrets.get', {
|
|
1113
1133
|
module: 'secrets',
|
|
1114
1134
|
bundle: name,
|
|
@@ -1120,32 +1140,30 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1120
1140
|
return filtered;
|
|
1121
1141
|
}
|
|
1122
1142
|
}
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
|
|
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) {
|
|
1135
1157
|
let noAclBundle = false;
|
|
1136
1158
|
try {
|
|
1137
1159
|
noAclBundle = bundlePolicy(readBundle(name)) === 'never';
|
|
1138
1160
|
}
|
|
1139
|
-
catch {
|
|
1140
|
-
noAclBundle = false;
|
|
1141
|
-
}
|
|
1161
|
+
catch { /* fail closed */ }
|
|
1142
1162
|
if (!noAclBundle) {
|
|
1143
|
-
throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent
|
|
1144
|
-
`
|
|
1145
|
-
`
|
|
1146
|
-
`first, or set AGENTS_SECRETS_NO_PROMPT=0 to force an interactive prompt.`);
|
|
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.`);
|
|
1147
1166
|
}
|
|
1148
|
-
// never/no-ACL ⇒ fall through to the prompt-less keychain read below.
|
|
1149
1167
|
}
|
|
1150
1168
|
if (backend === 'file')
|
|
1151
1169
|
assertFileBackendUsable(name);
|
|
@@ -1164,12 +1182,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1164
1182
|
const reason = opts.caller
|
|
1165
1183
|
? `read ${name} secrets (for ${opts.caller})`
|
|
1166
1184
|
: `read ${name} secrets`;
|
|
1167
|
-
void reason;
|
|
1168
1185
|
// secretItems are storage names as enumerated (opaque hashed names on macOS
|
|
1169
1186
|
// with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
|
|
1170
1187
|
// hashed inside getBatch. Deduped because the hashed enumeration spans the
|
|
1171
1188
|
// bundle's whole namespace.
|
|
1172
|
-
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])]);
|
|
1173
1199
|
const json = fetched.get(metaItem);
|
|
1174
1200
|
if (json === undefined) {
|
|
1175
1201
|
// For a file-backed bundle the metadata item is on disk (that's how
|
|
@@ -1297,7 +1323,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1297
1323
|
bundlePolicy(bundle) === 'daily' &&
|
|
1298
1324
|
secretsAgentAutoEnabled() &&
|
|
1299
1325
|
canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
|
|
1300
|
-
agentAutoLoadSync(name, bundle, env, secretsHoldMs());
|
|
1326
|
+
agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || 'cli');
|
|
1301
1327
|
}
|
|
1302
1328
|
return { bundle, env };
|
|
1303
1329
|
}
|
|
@@ -1306,6 +1332,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
1306
1332
|
throw err;
|
|
1307
1333
|
}
|
|
1308
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
|
+
}
|
|
1309
1345
|
// Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
|
|
1310
1346
|
export function keychainRef(key) {
|
|
1311
1347
|
return `keychain:${key}`;
|
|
@@ -210,7 +210,16 @@ export declare function hasKeychainToken(item: string): boolean;
|
|
|
210
210
|
* call in the same process). For bundles, prefer getKeychainTokens() so a
|
|
211
211
|
* single biometric prompt covers every key in the batch.
|
|
212
212
|
*/
|
|
213
|
-
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;
|
|
214
223
|
/**
|
|
215
224
|
* Batch-read multiple keychain items behind a single Touch ID prompt. The
|
|
216
225
|
* macOS helper holds one LAContext for its whole process: the first protected
|
|
@@ -221,7 +230,7 @@ export declare function getKeychainToken(item: string): string;
|
|
|
221
230
|
* On Linux or when a test backend is installed, falls back to individual
|
|
222
231
|
* lookups — no biometric prompt path on those platforms.
|
|
223
232
|
*/
|
|
224
|
-
export declare function getKeychainTokens(items: string[]): Map<string, string>;
|
|
233
|
+
export declare function getKeychainTokens(items: string[], context?: KeychainReadContext): Map<string, string>;
|
|
225
234
|
/** Store or update a secret value in the keychain/keyring. Device-local;
|
|
226
235
|
* biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
|
|
227
236
|
* item WITHOUT the biometry access control so later reads are fully silent — it
|
|
@@ -707,14 +707,14 @@ export function hasKeychainToken(item) {
|
|
|
707
707
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
708
708
|
}).status === 0;
|
|
709
709
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
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 = {}) {
|
|
718
718
|
// Errors keep the requested (human-readable) name; the storage name may be
|
|
719
719
|
// an opaque hash.
|
|
720
720
|
const requested = item;
|
|
@@ -739,6 +739,11 @@ export function getKeychainToken(item) {
|
|
|
739
739
|
}
|
|
740
740
|
const bin = getKeychainHelperPath();
|
|
741
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
|
+
},
|
|
742
747
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
743
748
|
});
|
|
744
749
|
if (result.status === 1)
|
|
@@ -764,7 +769,7 @@ export function getKeychainToken(item) {
|
|
|
764
769
|
* On Linux or when a test backend is installed, falls back to individual
|
|
765
770
|
* lookups — no biometric prompt path on those platforms.
|
|
766
771
|
*/
|
|
767
|
-
export function getKeychainTokens(items) {
|
|
772
|
+
export function getKeychainTokens(items, context = {}) {
|
|
768
773
|
const result = new Map();
|
|
769
774
|
if (items.length === 0)
|
|
770
775
|
return result;
|
|
@@ -811,6 +816,13 @@ export function getKeychainTokens(items) {
|
|
|
811
816
|
}
|
|
812
817
|
const bin = getKeychainHelperPath();
|
|
813
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
|
+
},
|
|
814
826
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
815
827
|
});
|
|
816
828
|
if (child.status === 4) {
|
|
@@ -38,12 +38,14 @@ export interface SessionEntry {
|
|
|
38
38
|
expiresAt: number;
|
|
39
39
|
/** true only for `--durable` unlocks — survives SLEEP. */
|
|
40
40
|
sleepPersist: boolean;
|
|
41
|
+
harness?: string;
|
|
41
42
|
}
|
|
42
43
|
/** Metadata for one held bundle, kept in the index so we can rehydrate / prune
|
|
43
44
|
* without reading every blob. */
|
|
44
45
|
export interface SessionIndexMeta {
|
|
45
46
|
expiresAt: number;
|
|
46
47
|
sleepPersist: boolean;
|
|
48
|
+
harness?: string;
|
|
47
49
|
}
|
|
48
50
|
export interface SessionIndex {
|
|
49
51
|
bundles: Record<string, SessionIndexMeta>;
|
|
@@ -73,9 +75,11 @@ export declare function writeIndex(index: SessionIndex): void;
|
|
|
73
75
|
/** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
|
|
74
76
|
export declare function saveSession(name: string, entry: SessionEntry): void;
|
|
75
77
|
/** Read one session blob by known name. Null when absent/expired/malformed. */
|
|
76
|
-
export declare function loadSession(name: string, now?: number): SessionEntry | null;
|
|
78
|
+
export declare function loadSession(name: string, now?: number, harness?: string): SessionEntry | null;
|
|
79
|
+
/** Remove every persisted harness grant for one bundle. */
|
|
80
|
+
export declare function deleteBundleSessions(name: string): void;
|
|
77
81
|
/** Delete one bundle's session blob and prune it from the index. */
|
|
78
|
-
export declare function deleteSession(name: string): void;
|
|
82
|
+
export declare function deleteSession(name: string, harness?: string): void;
|
|
79
83
|
/** Delete every session blob + the index (for `secrets lock --all`). */
|
|
80
84
|
export declare function deleteAllSessions(): void;
|
|
81
85
|
/** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
|
|
@@ -82,8 +82,8 @@ export function removeEntry(index, name) {
|
|
|
82
82
|
function shouldPersist() {
|
|
83
83
|
return process.platform === 'darwin' || isKeychainBackendOverridden();
|
|
84
84
|
}
|
|
85
|
-
function sessionBlobItem(name) {
|
|
86
|
-
return `${SESSION_ITEM_PREFIX}${name}`;
|
|
85
|
+
function sessionBlobItem(name, harness) {
|
|
86
|
+
return `${SESSION_ITEM_PREFIX}${harness}.${name}`;
|
|
87
87
|
}
|
|
88
88
|
/** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
|
|
89
89
|
export function readIndex() {
|
|
@@ -116,24 +116,26 @@ export function saveSession(name, entry) {
|
|
|
116
116
|
if (!shouldPersist())
|
|
117
117
|
return;
|
|
118
118
|
try {
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const harness = entry.harness || 'cli';
|
|
120
|
+
const key = `${harness}:${name}`;
|
|
121
|
+
setKeychainToken(sessionBlobItem(name, harness), JSON.stringify({ ...entry, harness }), { noAcl: true });
|
|
122
|
+
writeIndex(upsertEntry(readIndex(), key, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist, harness }));
|
|
121
123
|
}
|
|
122
124
|
catch {
|
|
123
125
|
/* best-effort — persistence is an optimization */
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
/** Read one session blob by known name. Null when absent/expired/malformed. */
|
|
127
|
-
export function loadSession(name, now = Date.now()) {
|
|
129
|
+
export function loadSession(name, now = Date.now(), harness = 'cli') {
|
|
128
130
|
if (!shouldPersist())
|
|
129
131
|
return null;
|
|
130
132
|
try {
|
|
131
|
-
const raw = getKeychainToken(sessionBlobItem(name));
|
|
133
|
+
const raw = getKeychainToken(sessionBlobItem(name, harness));
|
|
132
134
|
const entry = JSON.parse(raw);
|
|
133
135
|
if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
|
|
134
136
|
return null;
|
|
135
137
|
if (now >= entry.expiresAt) {
|
|
136
|
-
deleteSession(name); // drop expired on read
|
|
138
|
+
deleteSession(name, harness); // drop expired on read
|
|
137
139
|
return null;
|
|
138
140
|
}
|
|
139
141
|
return entry;
|
|
@@ -142,13 +144,32 @@ export function loadSession(name, now = Date.now()) {
|
|
|
142
144
|
return null;
|
|
143
145
|
}
|
|
144
146
|
}
|
|
147
|
+
/** Remove every persisted harness grant for one bundle. */
|
|
148
|
+
export function deleteBundleSessions(name) {
|
|
149
|
+
if (!shouldPersist())
|
|
150
|
+
return;
|
|
151
|
+
const index = readIndex();
|
|
152
|
+
let next = index;
|
|
153
|
+
for (const [key, meta] of Object.entries(index.bundles)) {
|
|
154
|
+
const scopedName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
|
|
155
|
+
if (scopedName !== name)
|
|
156
|
+
continue;
|
|
157
|
+
const harness = meta.harness || 'cli';
|
|
158
|
+
try {
|
|
159
|
+
deleteKeychainToken(key.includes(':') ? sessionBlobItem(name, harness) : `${SESSION_ITEM_PREFIX}${name}`);
|
|
160
|
+
}
|
|
161
|
+
catch { /* keep going */ }
|
|
162
|
+
next = removeEntry(next, key);
|
|
163
|
+
}
|
|
164
|
+
writeIndex(next);
|
|
165
|
+
}
|
|
145
166
|
/** Delete one bundle's session blob and prune it from the index. */
|
|
146
|
-
export function deleteSession(name) {
|
|
167
|
+
export function deleteSession(name, harness = 'cli') {
|
|
147
168
|
if (!shouldPersist())
|
|
148
169
|
return;
|
|
149
170
|
try {
|
|
150
|
-
deleteKeychainToken(sessionBlobItem(name));
|
|
151
|
-
writeIndex(removeEntry(readIndex(), name));
|
|
171
|
+
deleteKeychainToken(sessionBlobItem(name, harness));
|
|
172
|
+
writeIndex(removeEntry(readIndex(), `${harness}:${name}`));
|
|
152
173
|
}
|
|
153
174
|
catch {
|
|
154
175
|
/* best-effort */
|
|
@@ -160,8 +181,12 @@ export function deleteAllSessions() {
|
|
|
160
181
|
return;
|
|
161
182
|
try {
|
|
162
183
|
for (const name of Object.keys(readIndex().bundles)) {
|
|
184
|
+
const meta = readIndex().bundles[name];
|
|
185
|
+
const bundleName = name.includes(':') ? name.split(':').slice(1).join(':') : name;
|
|
163
186
|
try {
|
|
164
|
-
deleteKeychainToken(
|
|
187
|
+
deleteKeychainToken(name.includes(':')
|
|
188
|
+
? sessionBlobItem(bundleName, meta.harness || 'cli')
|
|
189
|
+
: `${SESSION_ITEM_PREFIX}${bundleName}`);
|
|
165
190
|
}
|
|
166
191
|
catch { /* keep going */ }
|
|
167
192
|
}
|
|
@@ -180,19 +205,43 @@ export function rehydrateSessions(now = Date.now()) {
|
|
|
180
205
|
const out = [];
|
|
181
206
|
try {
|
|
182
207
|
const index = readIndex();
|
|
208
|
+
// One-time source migration from the pre-harness layout. Move each legacy
|
|
209
|
+
// bundle-name index/blob to the explicit cli scope, then delete the old blob.
|
|
210
|
+
let migratedIndex = index;
|
|
211
|
+
for (const [key, meta] of Object.entries(index.bundles)) {
|
|
212
|
+
if (key.includes(':'))
|
|
213
|
+
continue;
|
|
214
|
+
try {
|
|
215
|
+
const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
|
|
216
|
+
const legacy = JSON.parse(raw);
|
|
217
|
+
setKeychainToken(sessionBlobItem(key, 'cli'), JSON.stringify({ ...legacy, harness: 'cli' }), { noAcl: true });
|
|
218
|
+
deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
|
|
219
|
+
migratedIndex = upsertEntry(removeEntry(migratedIndex, key), `cli:${key}`, {
|
|
220
|
+
expiresAt: meta.expiresAt,
|
|
221
|
+
sleepPersist: meta.sleepPersist,
|
|
222
|
+
harness: 'cli',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch { /* malformed/absent legacy entry is pruned below */ }
|
|
226
|
+
}
|
|
227
|
+
writeIndex(migratedIndex);
|
|
228
|
+
index.bundles = migratedIndex.bundles;
|
|
183
229
|
const { survivors, expiredNames } = pruneExpired(index, now);
|
|
184
230
|
for (const name of expiredNames) {
|
|
231
|
+
const meta = index.bundles[name];
|
|
185
232
|
try {
|
|
186
|
-
deleteKeychainToken(sessionBlobItem(name));
|
|
233
|
+
deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || 'cli'));
|
|
187
234
|
}
|
|
188
235
|
catch { /* keep going */ }
|
|
189
236
|
}
|
|
190
237
|
if (expiredNames.length)
|
|
191
238
|
writeIndex(survivors);
|
|
192
239
|
for (const name of selectRehydratable(survivors, now)) {
|
|
193
|
-
const
|
|
240
|
+
const meta = survivors.bundles[name];
|
|
241
|
+
const bundleName = name.split(':').slice(1).join(':');
|
|
242
|
+
const entry = loadSession(bundleName, now, meta.harness || 'cli');
|
|
194
243
|
if (entry)
|
|
195
|
-
out.push({ name, entry });
|
|
244
|
+
out.push({ name: bundleName, entry });
|
|
196
245
|
}
|
|
197
246
|
}
|
|
198
247
|
catch {
|
|
@@ -208,8 +257,9 @@ export function pruneSessionsOnSleep() {
|
|
|
208
257
|
try {
|
|
209
258
|
const { survivors, deletedNames } = pruneOnSleep(readIndex());
|
|
210
259
|
for (const name of deletedNames) {
|
|
260
|
+
const meta = readIndex().bundles[name];
|
|
211
261
|
try {
|
|
212
|
-
deleteKeychainToken(sessionBlobItem(name));
|
|
262
|
+
deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || 'cli'));
|
|
213
263
|
}
|
|
214
264
|
catch { /* keep going */ }
|
|
215
265
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The subset of a `secrets.get` event this heuristic reads. */
|
|
2
|
+
export interface SecretGetRecord {
|
|
3
|
+
/** Bundle name the read targeted. */
|
|
4
|
+
bundle?: string;
|
|
5
|
+
/** Where the value came from: `agent` (broker hit) / `session` (durable
|
|
6
|
+
* unlock) are SILENT; anything else hit the keychain and thus prompted. */
|
|
7
|
+
source?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PromptedBundle {
|
|
10
|
+
name: string;
|
|
11
|
+
/** Number of prompting (keychain) reads in the window. */
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* From recent `secrets.get` records, find bundles read often enough via the
|
|
16
|
+
* keychain (i.e. NOT served silently by the broker or a durable session) to be
|
|
17
|
+
* worth unlocking once. A bundle currently `held` is already silent, so it is
|
|
18
|
+
* excluded; the caller further drops `never`/no-ACL bundles (which never prompt,
|
|
19
|
+
* so unlocking them is a no-op).
|
|
20
|
+
*
|
|
21
|
+
* @param records recent `secrets.get` events (any order)
|
|
22
|
+
* @param held bundle names the broker currently holds (silent reads)
|
|
23
|
+
* @param opts.minReads minimum prompting reads to surface a bundle (default 3)
|
|
24
|
+
*/
|
|
25
|
+
export declare function frequentlyPromptedBundles(records: SecretGetRecord[], held: Set<string>, opts?: {
|
|
26
|
+
minReads?: number;
|
|
27
|
+
}): PromptedBundle[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Usage-based unlock hints: surface bundles you keep getting a Touch ID prompt
|
|
2
|
+
// for, so `agents secrets status` can suggest unlocking them once instead of
|
|
3
|
+
// prompting on every read. Built on the existing `secrets.get` audit events
|
|
4
|
+
// (events.jsonl) — no new tracking. Pure + exported so the threshold/filter
|
|
5
|
+
// logic is unit-testable without the events sink or the keychain.
|
|
6
|
+
/**
|
|
7
|
+
* From recent `secrets.get` records, find bundles read often enough via the
|
|
8
|
+
* keychain (i.e. NOT served silently by the broker or a durable session) to be
|
|
9
|
+
* worth unlocking once. A bundle currently `held` is already silent, so it is
|
|
10
|
+
* excluded; the caller further drops `never`/no-ACL bundles (which never prompt,
|
|
11
|
+
* so unlocking them is a no-op).
|
|
12
|
+
*
|
|
13
|
+
* @param records recent `secrets.get` events (any order)
|
|
14
|
+
* @param held bundle names the broker currently holds (silent reads)
|
|
15
|
+
* @param opts.minReads minimum prompting reads to surface a bundle (default 3)
|
|
16
|
+
*/
|
|
17
|
+
export function frequentlyPromptedBundles(records, held, opts = {}) {
|
|
18
|
+
const minReads = opts.minReads ?? 3;
|
|
19
|
+
const counts = new Map();
|
|
20
|
+
for (const r of records) {
|
|
21
|
+
if (!r.bundle)
|
|
22
|
+
continue;
|
|
23
|
+
// A broker hit or a durable-session read raised no Touch ID sheet — only a
|
|
24
|
+
// read that fell through to the keychain did. Count those; they are the ones
|
|
25
|
+
// an unlock would silence.
|
|
26
|
+
if (r.source === 'agent' || r.source === 'session')
|
|
27
|
+
continue;
|
|
28
|
+
if (held.has(r.bundle))
|
|
29
|
+
continue;
|
|
30
|
+
counts.set(r.bundle, (counts.get(r.bundle) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
return [...counts.entries()]
|
|
33
|
+
.filter(([, n]) => n >= minReads)
|
|
34
|
+
.map(([name, count]) => ({ name, count }))
|
|
35
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
36
|
+
}
|
|
@@ -4,6 +4,7 @@ import { type HookSessionIndex } from './hook-sessions.js';
|
|
|
4
4
|
import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
|
|
5
5
|
import type { SessionAttachment } from './types.js';
|
|
6
6
|
import { type SessionProvenance } from './provenance.js';
|
|
7
|
+
import { type DeviceRegistry } from '../devices/registry.js';
|
|
7
8
|
import { type Presence } from './detached.js';
|
|
8
9
|
/**
|
|
9
10
|
* Per-PID `lsof` probes run bounded and staggered rather than as one parallel
|
|
@@ -353,6 +354,15 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
|
|
|
353
354
|
* terminal/headless row for the same session id.
|
|
354
355
|
*/
|
|
355
356
|
export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
|
|
357
|
+
/**
|
|
358
|
+
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
359
|
+
* registry object). Returns the device name + ssh login user when the IP is a
|
|
360
|
+
* known device address.
|
|
361
|
+
*/
|
|
362
|
+
export declare function matchOriginDevice(clientIp: string, reg: DeviceRegistry): {
|
|
363
|
+
device: string;
|
|
364
|
+
user?: string;
|
|
365
|
+
} | undefined;
|
|
356
366
|
/**
|
|
357
367
|
* Collapse rows that resolve to the *same* session — a session with many
|
|
358
368
|
* subagent/fork PIDs (all matched to one transcript file) would otherwise print
|
|
@@ -34,6 +34,7 @@ import { readSessionTailWithRaw } from './tail.js';
|
|
|
34
34
|
import { computeTokPerSec } from './throughput.js';
|
|
35
35
|
import { inferSessionState } from './state.js';
|
|
36
36
|
import { detectProvenance } from './provenance.js';
|
|
37
|
+
import { loadDevices } from '../devices/registry.js';
|
|
37
38
|
import { presenceFromStore } from './detached.js';
|
|
38
39
|
import { mapBounded } from '../concurrency.js';
|
|
39
40
|
const execFileAsync = promisify(execFile);
|
|
@@ -1081,8 +1082,11 @@ export async function listTmuxAgentSessions() {
|
|
|
1081
1082
|
const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
|
|
1082
1083
|
const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
|
|
1083
1084
|
const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1085
|
+
// The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
|
|
1086
|
+
// stamp them off the pane. `transport:'local'` is only a placeholder: the pane
|
|
1087
|
+
// can't reveal how the shell above it was reached. enrichProvenance later reads
|
|
1088
|
+
// the pane process's env and upgrades this to 'ssh' (with the real origin) when
|
|
1089
|
+
// SSH_CONNECTION is present, while preserving this mux/reply.
|
|
1086
1090
|
const provenance = {
|
|
1087
1091
|
host: os.hostname(),
|
|
1088
1092
|
transport: 'local',
|
|
@@ -1135,6 +1139,7 @@ export async function getActiveSessions(opts = {}) {
|
|
|
1135
1139
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
1136
1140
|
const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
|
|
1137
1141
|
await enrichProvenance(merged);
|
|
1142
|
+
await resolveOrigins(merged);
|
|
1138
1143
|
foldPresence(merged);
|
|
1139
1144
|
return merged;
|
|
1140
1145
|
}
|
|
@@ -1161,14 +1166,72 @@ function foldPresence(rows) {
|
|
|
1161
1166
|
* each session once, not once per fork pid. Probes run in parallel — each is a
|
|
1162
1167
|
* single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
|
|
1163
1168
|
* undefined rather than blocking the listing.
|
|
1169
|
+
*
|
|
1170
|
+
* A row that already carries provenance (the tmux path, which knows its exact
|
|
1171
|
+
* mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
|
|
1172
|
+
* path can only stamp a `transport:'local'` placeholder because the pane alone
|
|
1173
|
+
* doesn't reveal how the shell above it was reached; the process env does. So we
|
|
1174
|
+
* still read the env and fill in the real SSH origin/term, while preserving the
|
|
1175
|
+
* authoritative mux/reply the pane already gave us. Skipping this (the old
|
|
1176
|
+
* behavior) is exactly why ssh-launched tmux sessions rendered as local.
|
|
1164
1177
|
*/
|
|
1165
1178
|
async function enrichProvenance(sessions) {
|
|
1166
1179
|
await Promise.all(sessions.map(async (s) => {
|
|
1167
|
-
if (
|
|
1180
|
+
if (!s.pid)
|
|
1181
|
+
return;
|
|
1182
|
+
const probed = await detectProvenance(s.pid);
|
|
1183
|
+
if (!probed)
|
|
1184
|
+
return;
|
|
1185
|
+
if (!s.provenance) {
|
|
1186
|
+
s.provenance = probed;
|
|
1168
1187
|
return;
|
|
1169
|
-
|
|
1188
|
+
}
|
|
1189
|
+
// Row already carries exact mux/reply (the tmux path). Fill only what a
|
|
1190
|
+
// pre-set provenance can't know from the pane alone: the real launch origin.
|
|
1191
|
+
if (probed.transport === 'ssh' && !s.provenance.ssh) {
|
|
1192
|
+
s.provenance.transport = 'ssh';
|
|
1193
|
+
s.provenance.ssh = probed.ssh;
|
|
1194
|
+
}
|
|
1195
|
+
if (probed.term && !s.provenance.term)
|
|
1196
|
+
s.provenance.term = probed.term;
|
|
1170
1197
|
}));
|
|
1171
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Match an SSH client IP to a registered device (pure — testable with a plain
|
|
1201
|
+
* registry object). Returns the device name + ssh login user when the IP is a
|
|
1202
|
+
* known device address.
|
|
1203
|
+
*/
|
|
1204
|
+
export function matchOriginDevice(clientIp, reg) {
|
|
1205
|
+
for (const d of Object.values(reg)) {
|
|
1206
|
+
if (d.address?.ip && d.address.ip === clientIp) {
|
|
1207
|
+
return { device: d.name, ...(d.user ? { user: d.user } : {}) };
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Resolve the initiating device for every ssh-transport session by matching its
|
|
1214
|
+
* `ssh.clientIp` against the device registry. Read-only and best-effort: a
|
|
1215
|
+
* registry that can't be loaded, or an IP that matches no device, leaves
|
|
1216
|
+
* `origin` undefined (the raw client IP is still on `ssh`). Mutates in place.
|
|
1217
|
+
*/
|
|
1218
|
+
async function resolveOrigins(sessions) {
|
|
1219
|
+
const needing = sessions.filter((s) => s.provenance?.ssh && !s.provenance.origin);
|
|
1220
|
+
if (needing.length === 0)
|
|
1221
|
+
return;
|
|
1222
|
+
let reg;
|
|
1223
|
+
try {
|
|
1224
|
+
reg = await loadDevices();
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
for (const s of needing) {
|
|
1230
|
+
const match = matchOriginDevice(s.provenance.ssh.clientIp, reg);
|
|
1231
|
+
if (match)
|
|
1232
|
+
s.provenance.origin = match;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1172
1235
|
/**
|
|
1173
1236
|
* Identity for a row the scan could not tie to a session: a daemon's worker
|
|
1174
1237
|
* processes (an OpenClaw gateway spawning `codex`, a supervisor pool) have no
|