@phnx-labs/agents-cli 1.20.51 → 1.20.52
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 +16 -0
- package/dist/commands/browser.js +215 -7
- package/dist/commands/cloud.js +6 -0
- package/dist/commands/events.d.ts +1 -1
- package/dist/commands/events.js +2 -3
- package/dist/commands/exec.js +17 -2
- package/dist/commands/factory.js +8 -0
- package/dist/commands/feed.d.ts +9 -0
- package/dist/commands/feed.js +69 -0
- package/dist/commands/logs.d.ts +5 -1
- package/dist/commands/logs.js +248 -3
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/secrets.d.ts +22 -0
- package/dist/commands/secrets.js +173 -42
- package/dist/commands/teams.js +4 -0
- package/dist/index.js +6 -2
- package/dist/lib/browser/login-detection.d.ts +94 -0
- package/dist/lib/browser/login-detection.js +274 -0
- package/dist/lib/browser/profiles.d.ts +17 -8
- package/dist/lib/browser/profiles.js +27 -8
- package/dist/lib/browser/secret-ref.d.ts +10 -0
- package/dist/lib/browser/secret-ref.js +14 -0
- package/dist/lib/browser/service.js +14 -12
- package/dist/lib/cloud/rush.d.ts +15 -0
- package/dist/lib/cloud/rush.js +7 -1
- package/dist/lib/crabbox/lease.d.ts +6 -0
- package/dist/lib/crabbox/lease.js +11 -9
- package/dist/lib/crabbox/runtimes.d.ts +38 -1
- package/dist/lib/crabbox/runtimes.js +98 -5
- package/dist/lib/daemon.d.ts +12 -9
- package/dist/lib/daemon.js +32 -17
- package/dist/lib/events.d.ts +31 -5
- package/dist/lib/events.js +288 -101
- package/dist/lib/exec.js +1 -0
- package/dist/lib/feed.d.ts +56 -0
- package/dist/lib/feed.js +251 -0
- package/dist/lib/hooks.js +7 -2
- package/dist/lib/hosts/passthrough.js +1 -0
- package/dist/lib/rotate.js +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +21 -0
- package/dist/lib/secrets/agent.js +63 -1
- package/dist/lib/secrets/bundles.d.ts +33 -1
- package/dist/lib/secrets/bundles.js +38 -8
- package/dist/lib/secrets/icloud-import.d.ts +70 -0
- package/dist/lib/secrets/icloud-import.js +173 -0
- package/dist/lib/secrets/index.d.ts +36 -0
- package/dist/lib/secrets/index.js +99 -9
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/secrets/sync.js +1 -1
- package/dist/lib/session/discover.js +1 -2
- package/dist/lib/session/state.js +13 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +25 -8
- package/dist/lib/teams/agents.js +6 -3
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/whats-new.d.ts +5 -3
- package/dist/lib/whats-new.js +25 -5
- package/package.json +1 -1
package/dist/lib/feed.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed store -- structured block records published by agents waiting on user
|
|
3
|
+
* input (AskUserQuestion). The outbound counterpart to the inbound mailbox:
|
|
4
|
+
* the mailbox delivers messages TO agents; the feed surfaces decisions agents
|
|
5
|
+
* need FROM the user.
|
|
6
|
+
*
|
|
7
|
+
* Layout: <feedDir>/<blockId>.json
|
|
8
|
+
* Each file is one open block -- a question the agent asked. One block per
|
|
9
|
+
* session: a new AskUserQuestion in the same session replaces the previous
|
|
10
|
+
* block (an agent can only ask one question at a time). Removed when the
|
|
11
|
+
* session advances past the block.
|
|
12
|
+
*
|
|
13
|
+
* A block carries enough identity (sessionId, mailboxId, host, runtime) for
|
|
14
|
+
* `agents feed` to aggregate across hosts and for `agents message` to route
|
|
15
|
+
* a reply back to the right agent.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as yaml from 'yaml';
|
|
20
|
+
import { getFeedDir, getUserAgentsDir } from './state.js';
|
|
21
|
+
/**
|
|
22
|
+
* Stable block id for a session. One block per session -- a new question
|
|
23
|
+
* replaces the previous one (the agent can only ask one question at a time).
|
|
24
|
+
*/
|
|
25
|
+
export function blockIdForSession(sessionId) {
|
|
26
|
+
const safeSessionId = sessionId.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
27
|
+
return `block-${safeSessionId}`;
|
|
28
|
+
}
|
|
29
|
+
function blockPath(root, blockId) {
|
|
30
|
+
if (!/^[A-Za-z0-9._-]+$/.test(blockId)) {
|
|
31
|
+
throw new Error(`Invalid feed block id: ${blockId}`);
|
|
32
|
+
}
|
|
33
|
+
return path.join(root, `${blockId}.json`);
|
|
34
|
+
}
|
|
35
|
+
/** Atomic write a block record to the feed store. */
|
|
36
|
+
export function publishBlock(block, root) {
|
|
37
|
+
const dir = root ?? getFeedDir();
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
const target = blockPath(dir, block.blockId);
|
|
40
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify(block, null, 2), 'utf-8');
|
|
42
|
+
fs.renameSync(tmp, target);
|
|
43
|
+
}
|
|
44
|
+
/** Read all block records. Returns them sorted by stable block filename. */
|
|
45
|
+
export function listBlocks(root) {
|
|
46
|
+
const dir = root ?? getFeedDir();
|
|
47
|
+
let names;
|
|
48
|
+
try {
|
|
49
|
+
names = fs.readdirSync(dir);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const blocks = [];
|
|
55
|
+
for (const name of names.filter(n => n.endsWith('.json')).sort()) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(path.join(dir, name), 'utf-8');
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (parsed.blockId && parsed.sessionId && parsed.questions?.length) {
|
|
60
|
+
blocks.push(parsed);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// skip corrupt / partial files
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return blocks;
|
|
68
|
+
}
|
|
69
|
+
/** Remove a block record. Returns true if the file was deleted. */
|
|
70
|
+
export function removeBlock(blockId, root) {
|
|
71
|
+
const dir = root ?? getFeedDir();
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(blockPath(dir, blockId));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Hook installation
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
|
|
85
|
+
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
86
|
+
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
87
|
+
*/
|
|
88
|
+
export const FEED_PUBLISH_HOOK_SCRIPT = `#!/usr/bin/env python3
|
|
89
|
+
"""PreToolUse hook: publish an open-block record when the agent calls
|
|
90
|
+
AskUserQuestion, so \`agents feed\` can aggregate pending decisions.
|
|
91
|
+
|
|
92
|
+
Outbound counterpart to the inbound mailbox-inject hook. Fires only on
|
|
93
|
+
AskUserQuestion (matcher-gated in agents.yaml). Writes one block per session
|
|
94
|
+
to ~/.agents/.history/feed/. A new question replaces the previous block.
|
|
95
|
+
|
|
96
|
+
Sub-agent gate: when the PreToolUse payload carries \`agent_type\`, this is a
|
|
97
|
+
Task/Agent subagent -- skip. Only the top-level agent publishes. Verified on
|
|
98
|
+
Claude Code 2.1.170 (2026-07).
|
|
99
|
+
|
|
100
|
+
Fail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.
|
|
101
|
+
"""
|
|
102
|
+
import os
|
|
103
|
+
import sys
|
|
104
|
+
import json
|
|
105
|
+
import re
|
|
106
|
+
import socket
|
|
107
|
+
import tempfile
|
|
108
|
+
from datetime import datetime, timezone
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main():
|
|
112
|
+
raw = sys.stdin.read()
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
115
|
+
except Exception:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Sub-agent gate.
|
|
119
|
+
if payload.get("agent_type"):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
tool_input = payload.get("tool_input", {})
|
|
123
|
+
questions = tool_input.get("questions", [])
|
|
124
|
+
if not questions:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
session_id = payload.get("session_id", "")
|
|
128
|
+
if not session_id:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
normalized_questions = []
|
|
132
|
+
for q in questions:
|
|
133
|
+
if not isinstance(q, dict):
|
|
134
|
+
continue
|
|
135
|
+
question = {
|
|
136
|
+
"text": q.get("question", q.get("header", "")),
|
|
137
|
+
"header": q.get("header"),
|
|
138
|
+
"multiSelect": q.get("multiSelect", False),
|
|
139
|
+
}
|
|
140
|
+
raw_opts = q.get("options", [])
|
|
141
|
+
if raw_opts:
|
|
142
|
+
question["options"] = [
|
|
143
|
+
{"label": o.get("label", ""), "description": o.get("description")}
|
|
144
|
+
for o in raw_opts
|
|
145
|
+
if isinstance(o, dict)
|
|
146
|
+
]
|
|
147
|
+
normalized_questions.append(question)
|
|
148
|
+
if not normalized_questions:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# Identity from env (set by agents-cli at spawn).
|
|
152
|
+
mailbox_id = os.path.basename(
|
|
153
|
+
os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
|
|
154
|
+
) or session_id
|
|
155
|
+
|
|
156
|
+
hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
|
|
157
|
+
host = hostname.split(".")[0].strip().lower()
|
|
158
|
+
host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
|
|
159
|
+
|
|
160
|
+
runtime = os.environ.get("AGENTS_RUNTIME", "headless")
|
|
161
|
+
|
|
162
|
+
safe_session_id = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
|
|
163
|
+
block_id = f"block-{safe_session_id}"
|
|
164
|
+
block = {
|
|
165
|
+
"blockId": block_id,
|
|
166
|
+
"sessionId": session_id,
|
|
167
|
+
"mailboxId": mailbox_id,
|
|
168
|
+
"host": host,
|
|
169
|
+
"runtime": runtime,
|
|
170
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
171
|
+
"questions": normalized_questions,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Python's expanduser() ignores HOME on Windows, while agents-cli honors a
|
|
175
|
+
# HOME override on every platform. Use the same anchor so hooks and the CLI
|
|
176
|
+
# always read/write one feed store (including temp-home and sandbox runs).
|
|
177
|
+
home = os.environ.get("HOME") or os.path.expanduser("~")
|
|
178
|
+
feed_dir = os.path.join(home, ".agents", ".history", "feed")
|
|
179
|
+
os.makedirs(feed_dir, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
target = os.path.join(feed_dir, f"{block_id}.json")
|
|
182
|
+
fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=".tmp")
|
|
183
|
+
try:
|
|
184
|
+
with os.fdopen(fd, "w") as f:
|
|
185
|
+
json.dump(block, f, indent=2)
|
|
186
|
+
os.rename(tmp, target)
|
|
187
|
+
except Exception:
|
|
188
|
+
try:
|
|
189
|
+
os.unlink(tmp)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
try:
|
|
196
|
+
main()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass # fail open
|
|
199
|
+
`;
|
|
200
|
+
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
201
|
+
export const FEED_PUBLISH_HOOK_MANIFEST = {
|
|
202
|
+
name: 'feed-publish',
|
|
203
|
+
events: ['PreToolUse'],
|
|
204
|
+
matcher: 'AskUserQuestion',
|
|
205
|
+
script: '10-feed-publish.py',
|
|
206
|
+
timeout: 5,
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Install the feed-publish hook script into the user hooks dir and add its
|
|
210
|
+
* manifest entry to the user agents.yaml. The system repo is an auto-pulled,
|
|
211
|
+
* read-only mirror, so runtime-managed hooks must never write there.
|
|
212
|
+
* Idempotent -- skips if the script is already present and up to date.
|
|
213
|
+
*/
|
|
214
|
+
export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
|
|
215
|
+
try {
|
|
216
|
+
const hooksDir = path.join(userAgentsDir, 'hooks');
|
|
217
|
+
const scriptPath = path.join(hooksDir, '10-feed-publish.py');
|
|
218
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
219
|
+
let installed = false;
|
|
220
|
+
if (!fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, 'utf-8') !== FEED_PUBLISH_HOOK_SCRIPT) {
|
|
221
|
+
const tmpScript = `${scriptPath}.${process.pid}.tmp`;
|
|
222
|
+
fs.writeFileSync(tmpScript, FEED_PUBLISH_HOOK_SCRIPT, { mode: 0o755 });
|
|
223
|
+
fs.renameSync(tmpScript, scriptPath);
|
|
224
|
+
installed = true;
|
|
225
|
+
}
|
|
226
|
+
const agentsYamlPath = path.join(userAgentsDir, 'agents.yaml');
|
|
227
|
+
const yamlDoc = fs.existsSync(agentsYamlPath)
|
|
228
|
+
? yaml.parseDocument(fs.readFileSync(agentsYamlPath, 'utf-8'))
|
|
229
|
+
: new yaml.Document({});
|
|
230
|
+
if (yamlDoc.errors.length > 0) {
|
|
231
|
+
throw new Error(`Cannot install feed hook: ${agentsYamlPath} is invalid YAML`);
|
|
232
|
+
}
|
|
233
|
+
if (!yamlDoc.getIn(['hooks', 'feed-publish'])) {
|
|
234
|
+
yamlDoc.setIn(['hooks', 'feed-publish'], {
|
|
235
|
+
agents: ['claude'],
|
|
236
|
+
events: ['PreToolUse'],
|
|
237
|
+
matcher: 'AskUserQuestion',
|
|
238
|
+
script: '10-feed-publish.py',
|
|
239
|
+
timeout: 5,
|
|
240
|
+
});
|
|
241
|
+
const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
|
|
242
|
+
fs.writeFileSync(tmpYaml, String(yamlDoc));
|
|
243
|
+
fs.renameSync(tmpYaml, agentsYamlPath);
|
|
244
|
+
installed = true;
|
|
245
|
+
}
|
|
246
|
+
return { installed };
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
return { installed: false, error: err.message };
|
|
250
|
+
}
|
|
251
|
+
}
|
package/dist/lib/hooks.js
CHANGED
|
@@ -1028,9 +1028,11 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
|
|
|
1028
1028
|
const configDir = path.join(versionHome, configDirName);
|
|
1029
1029
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
1030
1030
|
let config = {};
|
|
1031
|
+
let existingRaw;
|
|
1031
1032
|
if (fs.existsSync(settingsPath)) {
|
|
1032
1033
|
try {
|
|
1033
|
-
|
|
1034
|
+
existingRaw = fs.readFileSync(settingsPath, 'utf-8');
|
|
1035
|
+
config = JSON.parse(existingRaw);
|
|
1034
1036
|
}
|
|
1035
1037
|
catch {
|
|
1036
1038
|
errors.push('Failed to parse settings.json');
|
|
@@ -1111,7 +1113,10 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
|
|
|
1111
1113
|
}
|
|
1112
1114
|
try {
|
|
1113
1115
|
fs.mkdirSync(configDir, { recursive: true });
|
|
1114
|
-
|
|
1116
|
+
const nextRaw = JSON.stringify(config, null, 2);
|
|
1117
|
+
if (existingRaw !== nextRaw) {
|
|
1118
|
+
fs.writeFileSync(settingsPath, nextRaw, 'utf-8');
|
|
1119
|
+
}
|
|
1115
1120
|
}
|
|
1116
1121
|
catch (err) {
|
|
1117
1122
|
errors.push(`Failed to write settings.json: ${err.message}`);
|
|
@@ -34,6 +34,7 @@ const REMOTE_PASSTHROUGH = {
|
|
|
34
34
|
sync: { nonInteractive: ['--yes'] },
|
|
35
35
|
teams: {},
|
|
36
36
|
message: {},
|
|
37
|
+
feed: {},
|
|
37
38
|
};
|
|
38
39
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
39
40
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
package/dist/lib/rotate.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getAccountInfo } from './agents.js';
|
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
13
|
+
import { emit } from './events.js';
|
|
13
14
|
import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
|
|
14
15
|
function getRotateDir() {
|
|
15
16
|
const dir = path.join(getHelpersDir(), 'rotate');
|
|
@@ -388,6 +389,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
|
|
|
388
389
|
}
|
|
389
390
|
recordRotationPick(agent, rotation.picked.version);
|
|
390
391
|
}
|
|
392
|
+
emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
|
|
391
393
|
return { version: rotation.picked.version, rotation };
|
|
392
394
|
}
|
|
393
395
|
return { version: fallback, rotation: null };
|
|
Binary file
|
|
Binary file
|
|
@@ -50,6 +50,18 @@ export declare const META_CACHE_PREFIX = "!meta:";
|
|
|
50
50
|
* cache on every bump and produced a recurring Touch ID storm.
|
|
51
51
|
*/
|
|
52
52
|
export declare function shouldSelfHealForUpgrade(persistent: boolean, storeSize: number, runningVersion: string, onDiskVersion: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
|
|
55
|
+
* tear down a reachable broker whose running version differs from the client's
|
|
56
|
+
* on-disk version. Only while it holds NO real unlocks — tearing down a hot
|
|
57
|
+
* broker wipes every held bundle, so the next read of each one re-prompts for
|
|
58
|
+
* Touch ID. On a machine where installed versions churn (dev builds stamp a
|
|
59
|
+
* fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
|
|
60
|
+
* turn), an unguarded teardown produced a rolling Touch ID storm — the exact
|
|
61
|
+
* failure #435 fixed on the server side. A hot, protocol-compatible broker
|
|
62
|
+
* keeps serving; its own sweep adopts the new code at the next quiet moment.
|
|
63
|
+
*/
|
|
64
|
+
export declare function shouldTeardownVersionSkewedBroker(realHeldBundles: number): boolean;
|
|
53
65
|
export interface StoredBundle {
|
|
54
66
|
bundle: SecretsBundle;
|
|
55
67
|
env: Record<string, string>;
|
|
@@ -172,6 +184,15 @@ export declare function agentGetSync(name: string): {
|
|
|
172
184
|
bundle: SecretsBundle;
|
|
173
185
|
env: Record<string, string>;
|
|
174
186
|
} | null;
|
|
187
|
+
/**
|
|
188
|
+
* Synchronously evict one bundle from the broker. Called after a mutating
|
|
189
|
+
* keychain write (add / rotate / remove / rename / delete) so the broker never
|
|
190
|
+
* keeps serving the pre-write snapshot for up to the ~7d hold — the next read
|
|
191
|
+
* re-resolves from the keychain (one prompt) and re-caches fresh values.
|
|
192
|
+
* Best-effort: no broker, no socket, or any failure is a silent no-op.
|
|
193
|
+
* macOS only.
|
|
194
|
+
*/
|
|
195
|
+
export declare function agentEvictSync(name: string): void;
|
|
175
196
|
/**
|
|
176
197
|
* Read the cached `secrets list` metadata snapshot for the given keychain
|
|
177
198
|
* name-set hash, or null on miss / no broker / off-darwin. Reuses the value
|
|
@@ -74,6 +74,20 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
|
|
|
74
74
|
return false;
|
|
75
75
|
return onDiskVersion !== runningVersion;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
|
|
79
|
+
* tear down a reachable broker whose running version differs from the client's
|
|
80
|
+
* on-disk version. Only while it holds NO real unlocks — tearing down a hot
|
|
81
|
+
* broker wipes every held bundle, so the next read of each one re-prompts for
|
|
82
|
+
* Touch ID. On a machine where installed versions churn (dev builds stamp a
|
|
83
|
+
* fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
|
|
84
|
+
* turn), an unguarded teardown produced a rolling Touch ID storm — the exact
|
|
85
|
+
* failure #435 fixed on the server side. A hot, protocol-compatible broker
|
|
86
|
+
* keeps serving; its own sweep adopts the new code at the next quiet moment.
|
|
87
|
+
*/
|
|
88
|
+
export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
|
|
89
|
+
return realHeldBundles === 0;
|
|
90
|
+
}
|
|
77
91
|
function onDarwin() {
|
|
78
92
|
return process.platform === 'darwin';
|
|
79
93
|
}
|
|
@@ -581,6 +595,50 @@ export function agentGetSync(name) {
|
|
|
581
595
|
return null;
|
|
582
596
|
}
|
|
583
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* Inline node program for the synchronous evict path. Mirrors SYNC_GET_PROGRAM:
|
|
600
|
+
* writeBundle is synchronous and called synchronously everywhere, so a stale
|
|
601
|
+
* broker entry must be evicted without awaiting a socket round-trip. Sends one
|
|
602
|
+
* {cmd:'lock', name} and exits 0 (evicted or nothing held) / 3 (agent down).
|
|
603
|
+
* argv after -e: [execPath, <socket>, <name>].
|
|
604
|
+
*/
|
|
605
|
+
const SYNC_LOCK_PROGRAM = `
|
|
606
|
+
const net = require('net');
|
|
607
|
+
const sock = process.argv[1], name = process.argv[2];
|
|
608
|
+
const c = net.createConnection(sock);
|
|
609
|
+
let buf = '';
|
|
610
|
+
const down = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
|
|
611
|
+
const timer = setTimeout(down, 2000);
|
|
612
|
+
c.on('error', down);
|
|
613
|
+
c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name }) + '\\n'));
|
|
614
|
+
c.setEncoding('utf-8');
|
|
615
|
+
c.on('data', (d) => {
|
|
616
|
+
buf += d;
|
|
617
|
+
const nl = buf.indexOf('\\n');
|
|
618
|
+
if (nl < 0) return;
|
|
619
|
+
clearTimeout(timer);
|
|
620
|
+
try { c.destroy(); } catch (e) {}
|
|
621
|
+
process.exit(0);
|
|
622
|
+
});
|
|
623
|
+
`;
|
|
624
|
+
/**
|
|
625
|
+
* Synchronously evict one bundle from the broker. Called after a mutating
|
|
626
|
+
* keychain write (add / rotate / remove / rename / delete) so the broker never
|
|
627
|
+
* keeps serving the pre-write snapshot for up to the ~7d hold — the next read
|
|
628
|
+
* re-resolves from the keychain (one prompt) and re-caches fresh values.
|
|
629
|
+
* Best-effort: no broker, no socket, or any failure is a silent no-op.
|
|
630
|
+
* macOS only.
|
|
631
|
+
*/
|
|
632
|
+
export function agentEvictSync(name) {
|
|
633
|
+
if (!onDarwin())
|
|
634
|
+
return;
|
|
635
|
+
if (!agentSocketExists())
|
|
636
|
+
return;
|
|
637
|
+
try {
|
|
638
|
+
spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name], { timeout: 3000 });
|
|
639
|
+
}
|
|
640
|
+
catch { /* best-effort */ }
|
|
641
|
+
}
|
|
584
642
|
// Key inside the cached entry's env that holds the JSON metadata snapshot.
|
|
585
643
|
const META_SNAPSHOT_KEY = '__snapshot__';
|
|
586
644
|
/**
|
|
@@ -733,11 +791,15 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
733
791
|
// Self-heal: if a broker is reachable but running pre-upgrade code (its
|
|
734
792
|
// reported version != the version on disk now), tear it down so the paths
|
|
735
793
|
// below bring up a fresh one on current code. A current, reachable broker is
|
|
736
|
-
// accepted immediately
|
|
794
|
+
// accepted immediately — and so is a version-skewed one that still holds
|
|
795
|
+
// real unlocks (see shouldTeardownVersionSkewedBroker: wiping a hot cache
|
|
796
|
+
// re-prompts Touch ID for every held bundle).
|
|
737
797
|
const ping = await agentPing();
|
|
738
798
|
if (ping.reachable) {
|
|
739
799
|
if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
|
|
740
800
|
return true;
|
|
801
|
+
if (!shouldTeardownVersionSkewedBroker((await agentStatus()).length))
|
|
802
|
+
return true;
|
|
741
803
|
await teardownStaleBroker();
|
|
742
804
|
}
|
|
743
805
|
// Path 1: the persistent service. installSecretsAgentService is idempotent and
|
|
@@ -89,6 +89,9 @@ export interface LegacyBundleCandidate {
|
|
|
89
89
|
file: string;
|
|
90
90
|
keys: string[];
|
|
91
91
|
}
|
|
92
|
+
export declare const BUNDLE_NAME_PATTERN: RegExp;
|
|
93
|
+
export declare const ENV_KEY_PATTERN: RegExp;
|
|
94
|
+
export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
|
|
92
95
|
export declare const RESERVED_ENV_NAMES: Set<string>;
|
|
93
96
|
export declare function bundleToEnvPrefix(name: string): string;
|
|
94
97
|
export declare function isReservedEnvName(key: string): boolean;
|
|
@@ -114,7 +117,29 @@ export declare function readBundle(name: string): SecretsBundle;
|
|
|
114
117
|
export declare function secretsDefaultPolicy(): SecretsPolicy;
|
|
115
118
|
/** The effective prompt policy of a bundle (absent ⇒ the configured default). */
|
|
116
119
|
export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
|
|
117
|
-
|
|
120
|
+
/** Options for writeBundle. */
|
|
121
|
+
export interface WriteBundleOptions {
|
|
122
|
+
/**
|
|
123
|
+
* Skip evicting the bundle from the secrets-agent broker after the write.
|
|
124
|
+
* Only for writers that change nothing the broker serves — today that is
|
|
125
|
+
* stampLastUsed (a usage-telemetry timestamp, fired on every broker HIT):
|
|
126
|
+
* evicting there would make the cache destroy itself on first use. Every
|
|
127
|
+
* mutating writer (add / rotate / remove / rename / policy / import) must
|
|
128
|
+
* leave this unset so a broker-held copy never serves stale values for up
|
|
129
|
+
* to the ~7d hold.
|
|
130
|
+
*/
|
|
131
|
+
skipBrokerEviction?: boolean;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Whether a bundle write should evict the broker-held copy. Pure + exported
|
|
135
|
+
* for regression coverage. Skips when the writer opted out (stampLastUsed),
|
|
136
|
+
* when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
|
|
137
|
+
* kill-switch the read fast-path honors), or when a test keychain backend is
|
|
138
|
+
* installed (an in-memory backend has no real keychain behind it, and a test
|
|
139
|
+
* writing bundle 'prod' must never evict the user's real 'prod' unlock).
|
|
140
|
+
*/
|
|
141
|
+
export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAgentEnv: string | undefined, backendOverridden: boolean): boolean;
|
|
142
|
+
export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
|
|
118
143
|
export declare function deleteBundle(name: string): boolean;
|
|
119
144
|
export declare function listBundles(): SecretsBundle[];
|
|
120
145
|
export interface BundleEntryInfo {
|
|
@@ -140,6 +165,13 @@ export interface ResolveBundleOptions {
|
|
|
140
165
|
* needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
|
|
141
166
|
*/
|
|
142
167
|
noAgent?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Resolve only from an already-unlocked secrets-agent snapshot. If the
|
|
170
|
+
* broker has no snapshot, fail before touching Keychain or any other store.
|
|
171
|
+
* Background processes use this to guarantee they never surface a biometric
|
|
172
|
+
* prompt that nobody can answer.
|
|
173
|
+
*/
|
|
174
|
+
agentOnly?: boolean;
|
|
143
175
|
/**
|
|
144
176
|
* Inject only this subset of keys from the bundle. Keys not in this list are
|
|
145
177
|
* silently excluded from the returned env map. An error is thrown if any
|
|
@@ -25,7 +25,7 @@ import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainTo
|
|
|
25
25
|
import { fileStore } from './filestore.js';
|
|
26
26
|
import { emit } from '../events.js';
|
|
27
27
|
import { readMeta } from '../state.js';
|
|
28
|
-
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
28
|
+
import { agentGetSync, agentAutoLoadSync, agentGetMetaSync, agentAutoLoadMetaSync, agentEvictSync, secretsAgentAutoEnabled, DEFAULT_TTL_MS } from './agent.js';
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
30
|
const keychainStore = {
|
|
31
31
|
has: hasKeychainToken,
|
|
@@ -105,9 +105,9 @@ export const SECRET_TYPES = [
|
|
|
105
105
|
];
|
|
106
106
|
/** Minimum gap between last_used updates so the keychain isn't written on every secrets injection. */
|
|
107
107
|
const LAST_USED_THROTTLE_MS = 60_000;
|
|
108
|
-
const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
109
|
-
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
110
|
-
const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
|
|
108
|
+
export const BUNDLE_NAME_PATTERN = /^[a-z0-9][a-z0-9\-_.]{0,48}$/i;
|
|
109
|
+
export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
110
|
+
export const BUNDLE_META_PREFIX = 'agents-cli.bundles.';
|
|
111
111
|
const SECRETS_ITEM_PREFIX = 'agents-cli.secrets.';
|
|
112
112
|
export const RESERVED_ENV_NAMES = new Set([
|
|
113
113
|
'PATH', 'HOME', 'USER', 'USERNAME', 'SHELL', 'PWD', 'OLDPWD',
|
|
@@ -277,7 +277,24 @@ export function secretsDefaultPolicy() {
|
|
|
277
277
|
export function bundlePolicy(bundle) {
|
|
278
278
|
return bundle.policy ?? secretsDefaultPolicy();
|
|
279
279
|
}
|
|
280
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Whether a bundle write should evict the broker-held copy. Pure + exported
|
|
282
|
+
* for regression coverage. Skips when the writer opted out (stampLastUsed),
|
|
283
|
+
* when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
|
|
284
|
+
* kill-switch the read fast-path honors), or when a test keychain backend is
|
|
285
|
+
* installed (an in-memory backend has no real keychain behind it, and a test
|
|
286
|
+
* writing bundle 'prod' must never evict the user's real 'prod' unlock).
|
|
287
|
+
*/
|
|
288
|
+
export function shouldEvictAfterBundleWrite(skipRequested, noAgentEnv, backendOverridden) {
|
|
289
|
+
if (skipRequested)
|
|
290
|
+
return false;
|
|
291
|
+
if (noAgentEnv === '1')
|
|
292
|
+
return false;
|
|
293
|
+
if (backendOverridden)
|
|
294
|
+
return false;
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
export function writeBundle(bundle, opts = {}) {
|
|
281
298
|
validateBundleName(bundle.name);
|
|
282
299
|
const backend = bundle.backend ?? 'keychain';
|
|
283
300
|
if (backend === 'file')
|
|
@@ -337,12 +354,20 @@ export function writeBundle(bundle) {
|
|
|
337
354
|
// no-ACL command is missing) rather than silently landing an ACL'd item.
|
|
338
355
|
itemStore(backend).set(bundleMetaItem(bundle.name), json, { noAcl: bundle.policy === 'never' });
|
|
339
356
|
emit('secrets.set', { module: 'secrets', bundle: bundle.name });
|
|
357
|
+
// A broker-held snapshot predates this write; evict it so the next read
|
|
358
|
+
// re-resolves from the keychain instead of serving stale values.
|
|
359
|
+
if (shouldEvictAfterBundleWrite(Boolean(opts.skipBrokerEviction), process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
360
|
+
agentEvictSync(bundle.name);
|
|
361
|
+
}
|
|
340
362
|
}
|
|
341
363
|
export function deleteBundle(name) {
|
|
342
364
|
validateBundleName(name);
|
|
343
365
|
const deleted = itemStore(bundleBackend(name)).delete(bundleMetaItem(name));
|
|
344
366
|
if (deleted) {
|
|
345
367
|
emit('secrets.delete', { module: 'secrets', bundle: name });
|
|
368
|
+
if (shouldEvictAfterBundleWrite(false, process.env.AGENTS_SECRETS_NO_AGENT, isKeychainBackendOverridden())) {
|
|
369
|
+
agentEvictSync(name);
|
|
370
|
+
}
|
|
346
371
|
}
|
|
347
372
|
return deleted;
|
|
348
373
|
}
|
|
@@ -513,7 +538,9 @@ function stampLastUsed(bundle) {
|
|
|
513
538
|
}
|
|
514
539
|
try {
|
|
515
540
|
bundle.last_used = new Date(nowMs).toISOString();
|
|
516
|
-
|
|
541
|
+
// skipBrokerEviction: this stamp fires on every broker HIT; letting it
|
|
542
|
+
// evict would make the cache destroy itself on first use.
|
|
543
|
+
writeBundle(bundle, { skipBrokerEviction: true });
|
|
517
544
|
}
|
|
518
545
|
catch {
|
|
519
546
|
// Swallow — telemetry must never block secret resolution.
|
|
@@ -694,7 +721,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
694
721
|
emit('secrets.get', {
|
|
695
722
|
module: 'secrets',
|
|
696
723
|
bundle: name,
|
|
697
|
-
|
|
724
|
+
operation: opts.caller,
|
|
698
725
|
status: 'success',
|
|
699
726
|
source: 'agent',
|
|
700
727
|
keyCount: Object.keys(filtered.env).length,
|
|
@@ -702,6 +729,9 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
702
729
|
return filtered;
|
|
703
730
|
}
|
|
704
731
|
}
|
|
732
|
+
if (opts.agentOnly) {
|
|
733
|
+
throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent.`);
|
|
734
|
+
}
|
|
705
735
|
if (backend === 'file')
|
|
706
736
|
assertFileBackendUsable(name);
|
|
707
737
|
const store = itemStore(backend);
|
|
@@ -784,7 +814,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
|
|
|
784
814
|
emit('secrets.get', {
|
|
785
815
|
module: 'secrets',
|
|
786
816
|
bundle: bundle.name,
|
|
787
|
-
|
|
817
|
+
operation: opts.caller,
|
|
788
818
|
status,
|
|
789
819
|
keyCount: keys.length,
|
|
790
820
|
keys,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery for LEGACY SYNCHRONIZABLE (iCloud Keychain) bundles.
|
|
3
|
+
*
|
|
4
|
+
* The pre-biometry helper era defaulted bundles to iCloud Keychain sync. The
|
|
5
|
+
* device-local cutover (biometry ACL + kSecAttrSynchronizable false on every
|
|
6
|
+
* query) orphaned those items: they still sync back via iCloud Keychain, but
|
|
7
|
+
* neither `secrets list` nor `migrate-acl` can see them. This module powers
|
|
8
|
+
* `agents secrets import --from icloud` — discover the orphaned bundles,
|
|
9
|
+
* re-import them as normal device-local bundles, and optionally purge the
|
|
10
|
+
* iCloud copies.
|
|
11
|
+
*
|
|
12
|
+
* The item-name scheme is the same one the modern store uses (see bundles.ts):
|
|
13
|
+
* metadata under `agents-cli.bundles.<name>`, one value per key under
|
|
14
|
+
* `agents-cli.secrets.<bundle>.<key>`. Env keys can never contain a dot
|
|
15
|
+
* (ENV_KEY_PATTERN), so splitting a secret service at its LAST dot recovers
|
|
16
|
+
* the bundle/key boundary even for dotted bundle names like `hetzner.com`.
|
|
17
|
+
*/
|
|
18
|
+
import { type SecretsBackend } from './bundles.js';
|
|
19
|
+
/** One orphaned iCloud bundle, as discovered from the synced item names. */
|
|
20
|
+
export interface SyncedBundleCandidate {
|
|
21
|
+
/** Bundle name derived from the iCloud service names. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Env keys that have a per-key secret item in the iCloud keychain. */
|
|
24
|
+
keys: string[];
|
|
25
|
+
/** True when an `agents-cli.bundles.<name>` metadata item exists in iCloud. */
|
|
26
|
+
hasMeta: boolean;
|
|
27
|
+
/** Every iCloud service name belonging to this candidate (the purge set). */
|
|
28
|
+
services: string[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Group raw synced service names into per-bundle candidates. Pure — separated
|
|
32
|
+
* from discovery so the parsing rules are unit-testable without a keychain.
|
|
33
|
+
*
|
|
34
|
+
* A bundle can surface as metadata only (`agents-cli.bundles.<name>`), as
|
|
35
|
+
* secret items only (`agents-cli.secrets.<bundle>.<KEY>` — metadata never
|
|
36
|
+
* synced), or both; all three shapes appear in real iCloud strays, so every
|
|
37
|
+
* one becomes a candidate.
|
|
38
|
+
*/
|
|
39
|
+
export declare function groupSyncedServices(services: string[]): SyncedBundleCandidate[];
|
|
40
|
+
/** Enumerate the iCloud keychain and return every orphaned bundle candidate. */
|
|
41
|
+
export declare function discoverSyncedBundles(): SyncedBundleCandidate[];
|
|
42
|
+
export interface ImportSyncedOptions {
|
|
43
|
+
/** Overwrite keys that already exist in the local bundle. */
|
|
44
|
+
force?: boolean;
|
|
45
|
+
/** Store imported values as literals in the bundle metadata (no keychain items). */
|
|
46
|
+
allPlaintext?: boolean;
|
|
47
|
+
/** Backend for a newly created bundle (existing bundles keep theirs). */
|
|
48
|
+
backend?: SecretsBackend;
|
|
49
|
+
/** Delete the iCloud copies of successfully-read items after import. */
|
|
50
|
+
purge?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface ImportSyncedResult {
|
|
53
|
+
name: string;
|
|
54
|
+
added: number;
|
|
55
|
+
skipped: number;
|
|
56
|
+
/** Keys whose iCloud value could not be read (left in place, never purged). */
|
|
57
|
+
missing: string[];
|
|
58
|
+
purged: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Import one discovered iCloud bundle into the local (device-local) store.
|
|
62
|
+
*
|
|
63
|
+
* Values come from the synced secret items; the synced metadata item, when
|
|
64
|
+
* present, contributes the description, literal vars, and non-keychain refs
|
|
65
|
+
* (env:/file:/exec: refs carry no stored secret, so copying the ref preserves
|
|
66
|
+
* them exactly). Existing local keys are skipped unless `force`. With `purge`,
|
|
67
|
+
* only services whose value was successfully read are deleted from iCloud —
|
|
68
|
+
* an unreadable item is never destroyed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function importSyncedBundle(candidate: SyncedBundleCandidate, opts?: ImportSyncedOptions): ImportSyncedResult;
|