@phnx-labs/agents-cli 1.20.35 → 1.20.36

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.
Files changed (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Zero-knowledge / JIT secret delivery over MCP (issue #333).
3
+ *
4
+ * A stdio MCP server exposing a single `get_secret(bundle, key)` tool. Unlike
5
+ * `agents secrets exec`, which spawns the child process with EVERY resolved
6
+ * value baked into `process.env` (any subprocess can `printenv` the lot — see
7
+ * buildSecretsExecEnv in src/commands/secrets.ts), this server hands an
8
+ * MCP-speaking agent framework ONE credential at a time, by name, at call
9
+ * time. The raw value is returned only inside the tool result: it never enters
10
+ * the child's environment, is never logged, and never appears in `tools/list`
11
+ * metadata (names only).
12
+ *
13
+ * Resolution reuses the canonical bundle read path
14
+ * (readAndResolveBundleEnv -> getKeychainToken); no keychain access is
15
+ * re-implemented here. On Linux the libsecret backend (or its encrypted-file
16
+ * fallback for locked/headless collections) applies transparently through that
17
+ * abstraction, so the server stays backend-agnostic and works unchanged on
18
+ * macOS.
19
+ *
20
+ * Transport: the MCP stdio transport is newline-delimited JSON-RPC 2.0 (one
21
+ * message per line, UTF-8, no embedded newlines). That framing is trivial, so
22
+ * we implement it directly rather than pull in the @modelcontextprotocol/sdk
23
+ * server dependency — keeping the tree dependency-free and the handler pure and
24
+ * unit-testable in-process.
25
+ */
26
+ import * as readline from 'readline';
27
+ import { listBundles, readAndResolveBundleEnv, readBundle, validateBundleName, } from './bundles.js';
28
+ /** MCP protocol revision this server negotiates. */
29
+ export const MCP_PROTOCOL_VERSION = '2024-11-05';
30
+ /** The single tool this server exposes. */
31
+ export const GET_SECRET_TOOL = 'get_secret';
32
+ /** serverInfo.name reported at `initialize`. */
33
+ export const MCP_SERVER_NAME = 'agents-secrets';
34
+ // JSON-RPC 2.0 error codes we use.
35
+ const JSONRPC_PARSE_ERROR = -32700;
36
+ const JSONRPC_INVALID_REQUEST = -32600;
37
+ const JSONRPC_METHOD_NOT_FOUND = -32601;
38
+ const JSONRPC_INVALID_PARAMS = -32602;
39
+ /**
40
+ * Enumerate every bundle and its key names WITHOUT resolving or reading any
41
+ * value. Mirrors how `agents secrets list` surfaces bundle metadata: names are
42
+ * safe to expose, values are not. `listBundles()` reads only the bundle
43
+ * metadata items (var definitions), never the secret items behind them.
44
+ */
45
+ export function listSecretMetadata() {
46
+ return listBundles()
47
+ .map((b) => ({
48
+ bundle: b.name,
49
+ description: b.description,
50
+ keys: Object.keys(b.vars).sort(),
51
+ }))
52
+ .sort((a, b) => a.bundle.localeCompare(b.bundle));
53
+ }
54
+ /**
55
+ * Resolve a single secret value by bundle + key through the canonical read
56
+ * path. Throws a clear error when the bundle or the key is absent.
57
+ *
58
+ * The bundle metadata is read first (readBundle) so a missing key is reported
59
+ * without triggering resolution — and, on macOS, without prompting Touch ID —
60
+ * for the other keys in the bundle.
61
+ */
62
+ export function resolveSecret(bundle, key) {
63
+ validateBundleName(bundle);
64
+ // readBundle throws `Secrets bundle '<name>' not found.` for a missing bundle.
65
+ const meta = readBundle(bundle);
66
+ if (!Object.prototype.hasOwnProperty.call(meta.vars, key)) {
67
+ const available = Object.keys(meta.vars).sort();
68
+ throw new Error(`Key '${key}' not found in bundle '${bundle}'.` +
69
+ (available.length ? ` Available keys: ${available.join(', ')}.` : ' Bundle has no keys.'));
70
+ }
71
+ const { env } = readAndResolveBundleEnv(bundle, { caller: 'secrets-mcp' });
72
+ const value = env[key];
73
+ if (value === undefined) {
74
+ throw new Error(`Key '${key}' in bundle '${bundle}' could not be resolved.`);
75
+ }
76
+ return value;
77
+ }
78
+ /** Build the `get_secret` tool definition, advertising available names only. */
79
+ function getSecretToolDefinition() {
80
+ let catalog = '(no bundles configured)';
81
+ try {
82
+ const meta = listSecretMetadata();
83
+ if (meta.length > 0) {
84
+ catalog = meta
85
+ .map((b) => `${b.bundle}: ${b.keys.length ? b.keys.join(', ') : '(no keys)'}`)
86
+ .join(' | ');
87
+ }
88
+ }
89
+ catch {
90
+ // Listing is best-effort decoration; never fail tools/list because the
91
+ // keychain enumeration hiccuped.
92
+ catalog = '(unavailable)';
93
+ }
94
+ return {
95
+ name: GET_SECRET_TOOL,
96
+ description: 'Fetch a single secret value by bundle + key, resolved at call time. The value ' +
97
+ 'is returned only in this tool result and never enters the process environment. ' +
98
+ `Available bundles and keys (names only): ${catalog}`,
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ bundle: { type: 'string', description: 'Secrets bundle name' },
103
+ key: { type: 'string', description: 'Key / env-var name within the bundle' },
104
+ },
105
+ required: ['bundle', 'key'],
106
+ additionalProperties: false,
107
+ },
108
+ };
109
+ }
110
+ function success(id, result) {
111
+ return { jsonrpc: '2.0', id, result };
112
+ }
113
+ function rpcError(id, code, message) {
114
+ return { jsonrpc: '2.0', id, error: { code, message } };
115
+ }
116
+ /**
117
+ * A tool-execution failure. Per the MCP spec, tool errors are reported IN the
118
+ * result (`isError: true`) — not as a JSON-RPC error — so the model sees and
119
+ * can react to the message. The value channel (`content`) still carries no
120
+ * secret here.
121
+ */
122
+ function toolError(id, message) {
123
+ return success(id, { content: [{ type: 'text', text: message }], isError: true });
124
+ }
125
+ function handleToolCall(id, params) {
126
+ const p = (params ?? {});
127
+ if (p.name !== GET_SECRET_TOOL) {
128
+ return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${String(p.name)}`);
129
+ }
130
+ const args = (p.arguments ?? {});
131
+ if (typeof args.bundle !== 'string' || !args.bundle || typeof args.key !== 'string' || !args.key) {
132
+ return toolError(id, `${GET_SECRET_TOOL} requires non-empty string 'bundle' and 'key' arguments.`);
133
+ }
134
+ try {
135
+ const value = resolveSecret(args.bundle, args.key);
136
+ return success(id, { content: [{ type: 'text', text: value }] });
137
+ }
138
+ catch (err) {
139
+ return toolError(id, err.message);
140
+ }
141
+ }
142
+ /**
143
+ * Handle one parsed JSON-RPC request. Returns the response object, or `null`
144
+ * for notifications (which take no reply). Pure — no I/O, no logging — so tests
145
+ * drive it directly.
146
+ */
147
+ export function handleMcpRequest(req, ctx = {}) {
148
+ const id = req.id ?? null;
149
+ const isNotification = req.id === undefined || req.id === null;
150
+ switch (req.method) {
151
+ case 'initialize':
152
+ return success(id, {
153
+ protocolVersion: MCP_PROTOCOL_VERSION,
154
+ capabilities: { tools: {} },
155
+ serverInfo: { name: MCP_SERVER_NAME, version: ctx.version ?? '0.0.0' },
156
+ });
157
+ case 'notifications/initialized':
158
+ case 'initialized':
159
+ return null; // notification — no response
160
+ case 'ping':
161
+ return success(id, {});
162
+ case 'tools/list':
163
+ return success(id, { tools: [getSecretToolDefinition()] });
164
+ case 'tools/call':
165
+ return handleToolCall(id, req.params);
166
+ default:
167
+ if (isNotification)
168
+ return null; // unknown notification — stay silent
169
+ return rpcError(id, JSONRPC_METHOD_NOT_FOUND, `Method not found: ${String(req.method)}`);
170
+ }
171
+ }
172
+ function writeMessage(output, msg) {
173
+ output.write(JSON.stringify(msg) + '\n');
174
+ }
175
+ /**
176
+ * Run the stdio MCP server: read newline-delimited JSON-RPC requests from
177
+ * `input`, write responses to `output`. Resolves when the input stream ends.
178
+ * Streams are injectable so tests can drive a full request/response loop
179
+ * without spawning a process.
180
+ */
181
+ export async function runSecretsMcpServer(opts = {}) {
182
+ const input = opts.input ?? process.stdin;
183
+ const output = opts.output ?? process.stdout;
184
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
185
+ for await (const line of rl) {
186
+ const trimmed = line.trim();
187
+ if (!trimmed)
188
+ continue;
189
+ let req;
190
+ try {
191
+ req = JSON.parse(trimmed);
192
+ }
193
+ catch {
194
+ writeMessage(output, rpcError(null, JSONRPC_PARSE_ERROR, 'Parse error'));
195
+ continue;
196
+ }
197
+ if (!req || typeof req !== 'object' || typeof req.method !== 'string') {
198
+ writeMessage(output, rpcError((req && req.id) ?? null, JSONRPC_INVALID_REQUEST, 'Invalid Request'));
199
+ continue;
200
+ }
201
+ const res = handleMcpRequest(req, { version: opts.version });
202
+ if (res)
203
+ writeMessage(output, res);
204
+ }
205
+ }
@@ -15,10 +15,18 @@
15
15
  * file-backend passphrase travels over ssh stdin (first line) so it never lands
16
16
  * in argv / `ps` / remote shell history. Nothing is persisted locally.
17
17
  */
18
- import { sshExec, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
18
+ import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
19
19
  import { resolveHost } from '../hosts/registry.js';
20
20
  import { sshTargetFor } from '../hosts/types.js';
21
+ import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
22
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
21
23
  const REMOTE_TIMEOUT_MS = 30_000;
24
+ /** Remote OS for a target string (bare alias matches a device entry; a raw
25
+ * `user@host` falls back to POSIX). Threaded into the command builder so a
26
+ * Windows host gets PowerShell instead of `bash -lc`. */
27
+ function osForTarget(target) {
28
+ return resolveRemoteOsSync(target.split('@').pop() ?? target);
29
+ }
22
30
  /**
23
31
  * Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
24
32
  * registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
@@ -77,8 +85,7 @@ export function splitBundleRef(ref) {
77
85
  * remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
78
86
  */
79
87
  export function remoteSecretsRaw(target, args, opts = {}) {
80
- const inner = ['agents', 'secrets', ...args].map(shellQuote).join(' ');
81
- const remoteCmd = `bash -lc ${shellQuote(inner)}`;
88
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
82
89
  return sshExec(target, remoteCmd, {
83
90
  timeoutMs: REMOTE_TIMEOUT_MS,
84
91
  input: opts.input,
@@ -100,8 +107,8 @@ export function remoteSecretsRaw(target, args, opts = {}) {
100
107
  */
101
108
  export async function remoteResolveEnv(target, bundle) {
102
109
  assertValidSshTarget(target);
103
- const exportCmd = `agents secrets export ${shellQuote(bundle)} --plaintext --format json`;
104
- const res = sshExec(target, `bash -lc ${shellQuote(exportCmd)}`, {
110
+ const remoteCmd = buildRemoteAgentsInvocation(['secrets', 'export', bundle, '--plaintext', '--format', 'json'], undefined, osForTarget(target));
111
+ const res = sshExec(target, remoteCmd, {
105
112
  timeoutMs: REMOTE_TIMEOUT_MS,
106
113
  });
107
114
  if (res.code !== 0) {
@@ -11,7 +11,7 @@
11
11
  * never leaves this module; the backend only ever sees ciphertext + KDF params.
12
12
  */
13
13
  import * as crypto from 'crypto';
14
- import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
14
+ import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
15
15
  import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
16
16
  import { rushSyncBackend } from './drivers/rush.js';
17
17
  // PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
@@ -93,14 +93,93 @@ function snapshotBundle(name) {
93
93
  }
94
94
  return { bundle, secrets };
95
95
  }
96
+ /**
97
+ * Materialize a decrypted snapshot locally, atomically.
98
+ *
99
+ * A naive "set each secret, then write metadata" is not crash-safe: if one
100
+ * `setKeychainToken` throws midway, the keychain is left with a half-applied
101
+ * set — some items carry the pulled values, others the old ones — and the
102
+ * bundle metadata may never be written, leaving orphaned/wrong items readable.
103
+ *
104
+ * So we snapshot the PRIOR value of every item we're about to touch, apply all
105
+ * writes, and on ANY failure roll back to exactly the pre-restore state
106
+ * (restore previously-existing items, delete items that didn't exist) before
107
+ * rethrowing. Metadata is committed only after every secret write succeeds; if
108
+ * that final write fails, the secret writes are rolled back too. Either the
109
+ * whole pull lands or the keychain is untouched.
110
+ */
96
111
  function restoreSnapshot(snap) {
97
112
  const bundle = snap.bundle;
98
113
  validateBundleName(bundle.name);
99
- for (const [shortId, value] of Object.entries(snap.secrets)) {
114
+ // Capture the pre-restore state of every item so a partial failure can be
115
+ // undone. hasKeychainToken never prompts; getKeychainToken reads the existing
116
+ // value via the same path the rest of the module uses.
117
+ const priors = [];
118
+ for (const shortId of Object.keys(snap.secrets)) {
100
119
  const item = secretsKeychainItem(bundle.name, shortId);
101
- setKeychainToken(item, value);
120
+ if (hasKeychainToken(item)) {
121
+ priors.push({ item, existed: true, value: getKeychainToken(item) });
122
+ }
123
+ else {
124
+ priors.push({ item, existed: false });
125
+ }
126
+ }
127
+ // Best-effort reversal to the captured pre-restore state. Each item is
128
+ // reverted independently so one failure doesn't abort the rest of the
129
+ // rollback. Reversal writes hit the SAME backend that just failed the restore
130
+ // (a locked/erroring keyring is the realistic trigger), so a reversal can
131
+ // itself throw — those items are collected and returned so the caller can
132
+ // report an INCOMPLETE rollback instead of falsely claiming a clean one.
133
+ const rollback = () => {
134
+ const stillDirty = [];
135
+ for (const prior of priors) {
136
+ try {
137
+ if (prior.existed)
138
+ setKeychainToken(prior.item, prior.value);
139
+ else
140
+ deleteKeychainToken(prior.item);
141
+ }
142
+ catch {
143
+ // Keep undoing the remaining items, but remember this one couldn't be
144
+ // reverted — it may now hold an orphaned or wrong value.
145
+ stillDirty.push(prior.item);
146
+ }
147
+ }
148
+ return stillDirty;
149
+ };
150
+ try {
151
+ for (const [shortId, value] of Object.entries(snap.secrets)) {
152
+ const item = secretsKeychainItem(bundle.name, shortId);
153
+ setKeychainToken(item, value);
154
+ }
155
+ }
156
+ catch (err) {
157
+ throw new Error(rollbackFailureMessage(bundle.name, 'writing secrets', err, rollback()));
158
+ }
159
+ // Commit metadata last. If it fails, undo the secret writes so nothing
160
+ // partial lingers.
161
+ try {
162
+ writeBundle(bundle);
163
+ }
164
+ catch (err) {
165
+ throw new Error(rollbackFailureMessage(bundle.name, 'writing metadata', err, rollback()));
166
+ }
167
+ }
168
+ /**
169
+ * Build the error thrown when a restore fails. When the rollback reverted every
170
+ * touched item (`dirty` empty) we truthfully say the keychain is back to the
171
+ * pre-restore state. When some reversal writes ALSO failed we must NOT claim a
172
+ * clean rollback — the message names the still-dirty items so the user knows the
173
+ * keychain is half-restored and which secrets to check.
174
+ */
175
+ function rollbackFailureMessage(name, phase, err, dirty) {
176
+ if (dirty.length === 0) {
177
+ return `Restore of bundle '${name}' failed while ${phase}; ` +
178
+ `rolled back to the pre-restore state. (${err.message})`;
102
179
  }
103
- writeBundle(bundle);
180
+ return `Restore of bundle '${name}' failed while ${phase}, and the rollback was INCOMPLETE — ` +
181
+ `these keychain items could not be reverted and may hold orphaned or wrong values: ${dirty.join(', ')}. ` +
182
+ `(original error: ${err.message})`;
104
183
  }
105
184
  /** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
106
185
  export async function pushBundle(name, opts) {
@@ -53,6 +53,10 @@ export const CRED_MAX_CREDENTIAL_BLOB_SIZE = 2560;
53
53
  */
54
54
  const PS_SCRIPT = `
55
55
  $ErrorActionPreference = 'Stop'
56
+ # Output must not depend on the console codepage: a windowsHide child owns a
57
+ # fresh hidden console whose codepage is the OEM default (cp437), not the
58
+ # terminal's UTF-8 — the Node side always decodes stdout/stderr as UTF-8.
59
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
56
60
  Add-Type -TypeDefinition @'
57
61
  using System;
58
62
  using System.Runtime.InteropServices;
@@ -178,9 +182,12 @@ try {
178
182
  exit 0
179
183
  }
180
184
  'set' {
181
- $value = [Console]::In.ReadToEnd()
182
- $bytes = [System.Text.Encoding]::UTF8.GetBytes($value)
183
- [AgentsCred]::Set($target, $bytes)
185
+ # Raw bytes, not [Console]::In text — text decoding runs through the
186
+ # console codepage and corrupts non-ASCII UTF-8 from the Node side.
187
+ $stdin = [Console]::OpenStandardInput()
188
+ $ms = New-Object System.IO.MemoryStream
189
+ $stdin.CopyTo($ms)
190
+ [AgentsCred]::Set($target, $ms.ToArray())
184
191
  exit 0
185
192
  }
186
193
  'delete' {
@@ -219,6 +226,9 @@ function runCred(op, opts) {
219
226
  input: opts.input,
220
227
  stdio: ['pipe', 'pipe', 'pipe'],
221
228
  maxBuffer: 16 * 1024 * 1024,
229
+ // Never flash a console window when the caller has no console of its own
230
+ // (the scheduler daemon resolves bundles through here every sync cycle).
231
+ windowsHide: true,
222
232
  });
223
233
  return {
224
234
  status: result.status,
@@ -231,6 +241,7 @@ function runCred(op, opts) {
231
241
  function powershellAvailable() {
232
242
  const result = spawnSync(POWERSHELL, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], {
233
243
  stdio: ['ignore', 'ignore', 'ignore'],
244
+ windowsHide: true,
234
245
  });
235
246
  return !result.error && result.status === 0;
236
247
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Read-only state assembly for `agents serve`.
3
+ *
4
+ * Reuses the SAME structured data the CLI already emits — no recomputation:
5
+ * - teams: {@link handleTasks} + {@link handleStatus} + {@link toTaskStatusSummary}
6
+ * (the shape behind `agents teams status --json`), plus a per-worktree
7
+ * `git diff` via {@link gitDiff}.
8
+ * - routines: {@link listJobs} (the shape behind `agents routines list --json`).
9
+ * - cloud: {@link listTasks} from the local SQLite store (behind `agents cloud list --json`).
10
+ *
11
+ * Every panel is assembled independently: if one subsystem throws (e.g. the
12
+ * SQLite cloud store isn't provisioned on this box), that panel reports an
13
+ * `error` string and the others still render. This is an aggregator dashboard,
14
+ * not a fallback that hides a bug in a single code path.
15
+ */
16
+ import { AgentManager } from '../teams/agents.js';
17
+ import type { AgentStatusSummary } from '../teams/api.js';
18
+ import type { JobConfig } from '../routines.js';
19
+ import type { CloudTask } from '../cloud/types.js';
20
+ /** One teammate's worktree and its current uncommitted diff. */
21
+ export interface WorktreeDiff {
22
+ agent_id: string;
23
+ name: string | null;
24
+ agent_type: string;
25
+ status: string;
26
+ worktree_name: string | null;
27
+ worktree_path: string | null;
28
+ pr_url: string | null;
29
+ /** Uncommitted `git diff HEAD` for the worktree, capped. '' when clean/absent. */
30
+ diff: string;
31
+ }
32
+ /** One team: its per-teammate status summary plus per-worktree diffs. */
33
+ export interface TeamPanel {
34
+ task_name: string;
35
+ agent_count: number;
36
+ running: number;
37
+ completed: number;
38
+ failed: number;
39
+ agents: AgentStatusSummary[];
40
+ worktrees: WorktreeDiff[];
41
+ }
42
+ /** A panel that either carries data or an error string (never both). */
43
+ export type PanelResult<T> = {
44
+ ok: true;
45
+ data: T;
46
+ } | {
47
+ ok: false;
48
+ error: string;
49
+ };
50
+ /** The full read-only snapshot streamed to the browser. */
51
+ export interface ServeState {
52
+ generated_at: string;
53
+ teams: PanelResult<TeamPanel[]>;
54
+ routines: PanelResult<JobConfig[]>;
55
+ cloud: PanelResult<CloudTask[]>;
56
+ }
57
+ /** Shape of the teammate fields needed to build a worktree diff. */
58
+ export interface WorktreeAgentLike {
59
+ agentId: string;
60
+ name: string | null;
61
+ agentType: string;
62
+ status: string;
63
+ worktreeName: string | null;
64
+ worktreePath: string | null;
65
+ prUrl: string | null;
66
+ }
67
+ /**
68
+ * Build the per-worktree diffs for a set of teammates. Only teammates with a
69
+ * `worktreePath` produce a diff; each gets a real `git diff HEAD` via
70
+ * {@link gitDiff}. Pure over its inputs (no manager, no globals) so it can be
71
+ * unit-tested against a real temp git worktree.
72
+ */
73
+ export declare function buildWorktreeDiffs(agents: WorktreeAgentLike[]): Promise<WorktreeDiff[]>;
74
+ /**
75
+ * Assemble the full read-only snapshot. Each panel degrades independently to an
76
+ * `error` string so one unavailable subsystem never blanks the whole dashboard.
77
+ *
78
+ * @param cwd - Project root used for project-scoped routine discovery.
79
+ * @param manager - Injectable for tests; defaults to a fresh {@link AgentManager}.
80
+ */
81
+ export declare function assembleState(cwd?: string, manager?: AgentManager): Promise<ServeState>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Read-only state assembly for `agents serve`.
3
+ *
4
+ * Reuses the SAME structured data the CLI already emits — no recomputation:
5
+ * - teams: {@link handleTasks} + {@link handleStatus} + {@link toTaskStatusSummary}
6
+ * (the shape behind `agents teams status --json`), plus a per-worktree
7
+ * `git diff` via {@link gitDiff}.
8
+ * - routines: {@link listJobs} (the shape behind `agents routines list --json`).
9
+ * - cloud: {@link listTasks} from the local SQLite store (behind `agents cloud list --json`).
10
+ *
11
+ * Every panel is assembled independently: if one subsystem throws (e.g. the
12
+ * SQLite cloud store isn't provisioned on this box), that panel reports an
13
+ * `error` string and the others still render. This is an aggregator dashboard,
14
+ * not a fallback that hides a bug in a single code path.
15
+ */
16
+ import { AgentManager } from '../teams/agents.js';
17
+ import { handleTasks, handleStatus, toTaskStatusSummary } from '../teams/api.js';
18
+ import { gitDiff } from '../teams/worktree.js';
19
+ import { listJobs } from '../routines.js';
20
+ import { listTasks as listCloudTasks } from '../cloud/store.js';
21
+ /**
22
+ * Build the per-worktree diffs for a set of teammates. Only teammates with a
23
+ * `worktreePath` produce a diff; each gets a real `git diff HEAD` via
24
+ * {@link gitDiff}. Pure over its inputs (no manager, no globals) so it can be
25
+ * unit-tested against a real temp git worktree.
26
+ */
27
+ export async function buildWorktreeDiffs(agents) {
28
+ const withTree = agents.filter((a) => !!a.worktreePath);
29
+ return Promise.all(withTree.map(async (a) => ({
30
+ agent_id: a.agentId,
31
+ name: a.name,
32
+ agent_type: a.agentType,
33
+ status: a.status,
34
+ worktree_name: a.worktreeName,
35
+ worktree_path: a.worktreePath,
36
+ pr_url: a.prUrl,
37
+ diff: await gitDiff(a.worktreePath),
38
+ })));
39
+ }
40
+ /** Assemble the teams panel: overview counts, status summaries, worktree diffs. */
41
+ async function assembleTeams(manager) {
42
+ const { tasks } = await handleTasks(manager, 1000);
43
+ const panels = [];
44
+ for (const t of tasks) {
45
+ const status = toTaskStatusSummary(await handleStatus(manager, t.task_name, 'all'));
46
+ const teamAgents = await manager.listByTask(t.task_name);
47
+ const worktrees = await buildWorktreeDiffs(teamAgents.map((a) => ({
48
+ agentId: a.agentId,
49
+ name: a.name,
50
+ agentType: a.agentType,
51
+ status: a.status,
52
+ worktreeName: a.worktreeName,
53
+ worktreePath: a.worktreePath,
54
+ prUrl: a.prUrl,
55
+ })));
56
+ panels.push({
57
+ task_name: t.task_name,
58
+ agent_count: t.agent_count,
59
+ running: t.running,
60
+ completed: t.completed,
61
+ failed: t.failed,
62
+ agents: status.agents,
63
+ worktrees,
64
+ });
65
+ }
66
+ return panels;
67
+ }
68
+ /**
69
+ * Assemble the full read-only snapshot. Each panel degrades independently to an
70
+ * `error` string so one unavailable subsystem never blanks the whole dashboard.
71
+ *
72
+ * @param cwd - Project root used for project-scoped routine discovery.
73
+ * @param manager - Injectable for tests; defaults to a fresh {@link AgentManager}.
74
+ */
75
+ export async function assembleState(cwd = process.cwd(), manager = new AgentManager()) {
76
+ const [teams, routines, cloud] = await Promise.all([
77
+ assembleTeams(manager).then((data) => ({ ok: true, data }), (err) => ({ ok: false, error: String(err?.message ?? err) })),
78
+ Promise.resolve()
79
+ .then(() => listJobs(cwd))
80
+ .then((data) => ({ ok: true, data }), (err) => ({ ok: false, error: String(err?.message ?? err) })),
81
+ Promise.resolve()
82
+ .then(() => listCloudTasks({ limit: 50 }))
83
+ .then((data) => ({ ok: true, data }), (err) => ({ ok: false, error: String(err?.message ?? err) })),
84
+ ]);
85
+ return {
86
+ generated_at: new Date().toISOString(),
87
+ teams,
88
+ routines,
89
+ cloud,
90
+ };
91
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Self-contained HTML page for `agents serve`. No framework, no external assets
3
+ * — a single inline <style> + <script> that subscribes to /events (SSE) and
4
+ * re-renders the panels on each snapshot. Terminal-coded per the agents-cli
5
+ * brand: #0a0a0a bg, #a3e635 lime accent, JetBrains Mono.
6
+ */
7
+ export declare function renderPage(): string;