@phnx-labs/agents-cli 1.20.62 → 1.20.64

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 (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. package/package.json +1 -1
@@ -18,55 +18,83 @@ import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { R2Client } from './r2.js';
20
20
  import { loadR2Config, machineId } from './config.js';
21
- import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, SESSIONS_PREFIX, } from './agents.js';
21
+ import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, isMergeableFile, SESSIONS_PREFIX, } from './agents.js';
22
22
  import { mergeTranscripts, transcriptStats } from './crdt.js';
23
- import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, } from './manifest.js';
23
+ import { resolveSyncEncKey, encryptTranscript, decryptTranscriptBody } from './transcript-crypto.js';
24
+ import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, manifestEntries, } from './manifest.js';
24
25
  const nowIso = () => new Date().toISOString();
25
26
  function specById(id) {
26
27
  return SYNC_AGENTS.find(s => s.id === id);
27
28
  }
28
29
  /** Upload this machine's changed transcripts and publish its manifest. */
29
- async function pushOwn(r2, me, opts, result) {
30
+ async function pushOwn(r2, me, encKey, opts, result) {
30
31
  const ledger = loadLedger();
31
32
  const prev = loadLocalManifest();
32
33
  const manifest = emptyManifest(me, nowIso());
34
+ // The transcript body is sealed client-side before it leaves the machine
35
+ // (AES-256-GCM under the shared bundle key). Without a key we still upload —
36
+ // the feature predates encryption — but flag it LOUDLY once per cycle so an
37
+ // unencrypted upload never happens silently.
38
+ if (!encKey) {
39
+ result.warnings.push(`R2_SYNC_ENC_KEY not set in the r2.backups bundle — transcripts are uploaded UNENCRYPTED ` +
40
+ `(readable by anyone with bucket access). Add the shared key to enable client-side encryption.`);
41
+ }
33
42
  for (const spec of SYNC_AGENTS) {
34
43
  const agentManifest = {};
35
44
  for (const t of listLocalTranscripts(spec)) {
36
- let stat;
37
- try {
38
- stat = fs.statSync(t.absPath);
39
- }
40
- catch {
41
- continue;
42
- }
43
- const prevEntry = prev?.agents?.[spec.id]?.[t.sessionId];
44
- if (prevEntry && ledgerUnchanged(ledger, t.absPath, stat.size, stat.mtimeMs)) {
45
- agentManifest[t.sessionId] = prevEntry; // unchanged: reuse, no read, no upload
46
- result.pushSkipped++;
47
- continue;
48
- }
49
- let content;
50
- try {
51
- content = fs.readFileSync(t.absPath, 'utf-8');
52
- }
53
- catch {
54
- continue;
55
- }
56
- const hash = hashContent(content);
57
- const { lastTs } = transcriptStats(content);
58
- const entry = { relKey: t.relKey, size: stat.size, hash, lastTs };
59
- try {
60
- await r2.put(objectKey(me, spec.id, t.sessionId), content, 'application/x-ndjson');
61
- ledgerRecord(ledger, t.absPath, stat.size, stat.mtimeMs, hash);
62
- agentManifest[t.sessionId] = entry;
63
- result.pushed++;
64
- if (opts.verbose)
65
- opts.log?.(` push ${spec.id}/${t.sessionId.slice(0, 8)} (${stat.size}B)`);
66
- }
67
- catch (err) {
68
- result.errors.push(`push ${spec.id}/${t.sessionId}: ${err.message}`);
45
+ // A session is one file (file-shaped) or many (dir-shaped). Push each file
46
+ // that changed, reusing the prior manifest entry (keyed by relKey) for the
47
+ // ones the ledger shows unchanged. Object keys nest under the session id for
48
+ // dir-shaped agents and stay flat for file-shaped ones (unchanged keys).
49
+ const prevVal = prev?.agents?.[spec.id]?.[t.sessionId];
50
+ const prevByRel = new Map((prevVal ? manifestEntries(prevVal) : []).map(e => [e.relKey, e]));
51
+ const entries = [];
52
+ for (const f of t.files) {
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(f.absPath);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ const prevEntry = prevByRel.get(f.relKey);
61
+ if (prevEntry && ledgerUnchanged(ledger, f.absPath, stat.size, stat.mtimeMs)) {
62
+ entries.push(prevEntry); // unchanged: reuse, no read, no upload
63
+ result.pushSkipped++;
64
+ continue;
65
+ }
66
+ let content;
67
+ try {
68
+ content = fs.readFileSync(f.absPath, 'utf-8');
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ // Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
74
+ // non-deterministic), so hash + manifest are computed on cleartext; only
75
+ // the stored object body is sealed.
76
+ const hash = hashContent(content);
77
+ const lastTs = deriveLastTs(spec, f.relKey, content, stat.mtimeMs);
78
+ const entry = { relKey: f.relKey, size: stat.size, hash, lastTs };
79
+ const body = encKey ? encryptTranscript(content, encKey) : content;
80
+ const contentType = encKey ? 'application/json' : 'application/x-ndjson';
81
+ try {
82
+ await r2.put(objectKey(me, spec.id, t.sessionId, spec.dirShaped ? f.relKey : undefined), body, contentType);
83
+ ledgerRecord(ledger, f.absPath, stat.size, stat.mtimeMs, hash);
84
+ entries.push(entry);
85
+ result.pushed++;
86
+ const label = spec.dirShaped ? `${t.sessionId.slice(0, 8)}/${f.relKey}` : t.sessionId.slice(0, 8);
87
+ if (opts.verbose)
88
+ opts.log?.(` push ${spec.id}/${label} (${stat.size}B)`);
89
+ }
90
+ catch (err) {
91
+ result.errors.push(`push ${spec.id}/${t.sessionId}/${f.relKey}: ${err.message}`);
92
+ }
69
93
  }
94
+ // File-shaped: store the single entry (byte-identical to the old format so
95
+ // older CLIs read it unchanged). Dir-shaped: store the per-file array.
96
+ if (entries.length > 0)
97
+ agentManifest[t.sessionId] = spec.dirShaped ? entries : entries[0];
70
98
  }
71
99
  if (Object.keys(agentManifest).length > 0)
72
100
  manifest.agents[spec.id] = agentManifest;
@@ -101,17 +129,60 @@ export function selectSessionsToFetch(copies, localIdsByAgent, pullState) {
101
129
  return pending;
102
130
  }
103
131
  /**
104
- * Resolve the mirror destination + merged content for one session. Pure.
132
+ * Resolve the mirror destination + reconciled content for ONE file across its
133
+ * copies (every copy here is the same file — same relKey — held by a different
134
+ * machine). Pure.
135
+ *
105
136
  * The canonical path comes from the lexicographically-smallest machine so every
106
- * puller derives an identical location; the content is the CRDT union of copies.
137
+ * puller derives an identical location. The content depends on the file's kind
138
+ * (see `isMergeableFile`):
139
+ * - append-only logs (a transcript `.jsonl`) take the CRDT G-Set union — every
140
+ * machine converges to byte-identical output regardless of order.
141
+ * - mutable blobs (Kimi `state.json`) can't be line-unioned without corruption,
142
+ * so they resolve **last-writer-wins**: the copy with the latest event
143
+ * timestamp, tie-broken by content hash so the pick is deterministic fleet-wide.
144
+ */
145
+ /**
146
+ * The `lastTs` a manifest entry carries for one file. Append-only logs (a
147
+ * conversation `.jsonl`) embed per-line event timestamps, so their recency is
148
+ * the latest line timestamp (`transcriptStats`). Mutable blobs (Kimi
149
+ * `state.json`, the per-tool `tasks/*.json` sidecars) carry no event timestamp —
150
+ * their own `updatedAt`/`createdAt` fields are agent-specific and unreliable — so
151
+ * their "last written" signal is the file mtime. Without this, `transcriptStats`
152
+ * returns `''` for every blob and the last-writer-wins branch in
153
+ * `resolveMirrorWrite` silently degrades to "highest-hash-wins", which can pick a
154
+ * stale copy over the genuinely newer one.
107
155
  */
156
+ export function deriveLastTs(spec, relKey, content, mtimeMs) {
157
+ if (isMergeableFile(spec, relKey))
158
+ return transcriptStats(content).lastTs;
159
+ return new Date(mtimeMs).toISOString();
160
+ }
108
161
  export function resolveMirrorWrite(spec, copies, contents) {
109
162
  const canonical = [...copies].sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0))[0];
110
- const content = contents.length === 1 ? contents[0] : mergeTranscripts(contents);
163
+ let content;
164
+ if (contents.length === 1) {
165
+ content = contents[0];
166
+ }
167
+ else if (isMergeableFile(spec, canonical.entry.relKey)) {
168
+ content = mergeTranscripts(contents);
169
+ }
170
+ else {
171
+ // Last-writer-wins: highest (lastTs, hash) among the copies.
172
+ let win = 0;
173
+ for (let i = 1; i < copies.length; i++) {
174
+ const a = copies[i].entry, b = copies[win].entry;
175
+ if (a.lastTs > b.lastTs || (a.lastTs === b.lastTs && a.hash > b.hash))
176
+ win = i;
177
+ }
178
+ content = contents[win];
179
+ }
111
180
  return {
112
181
  dest: mirrorPath(spec, canonical.machine, canonical.entry.relKey),
113
182
  content,
114
- merged: contents.length > 1,
183
+ // "merged" means an actual CRDT line-union happened — not a last-writer-wins
184
+ // pick of one mutable blob over another (that discards a copy, it doesn't merge).
185
+ merged: contents.length > 1 && isMergeableFile(spec, canonical.entry.relKey),
115
186
  };
116
187
  }
117
188
  /**
@@ -138,7 +209,7 @@ export function reconcileCopies(spec, copies, fetched) {
138
209
  return resolveMirrorWrite(spec, copies, contents);
139
210
  }
140
211
  /** Fetch other machines' manifests, union changed sessions into the mirror. */
141
- async function pullAndReconcile(r2, me, opts, result) {
212
+ async function pullAndReconcile(r2, me, encKey, opts, result) {
142
213
  const prefixes = await r2.listPrefixes(SESSIONS_PREFIX); // sessions/<machine>/
143
214
  const machines = prefixes
144
215
  .map(p => p.slice(SESSIONS_PREFIX.length).replace(/\/$/, ''))
@@ -162,9 +233,13 @@ async function pullAndReconcile(r2, me, opts, result) {
162
233
  let byAgent = copies.get(agentId);
163
234
  if (!byAgent)
164
235
  copies.set(agentId, (byAgent = new Map()));
165
- for (const [sessionId, entry] of Object.entries(sessions)) {
236
+ for (const [sessionId, value] of Object.entries(sessions)) {
166
237
  const list = byAgent.get(sessionId) ?? [];
167
- list.push({ machine: m, entry });
238
+ // One RemoteCopy per (machine, file): file-shaped sessions contribute one,
239
+ // dir-shaped ones contribute an entry per constituent file. `manifestEntries`
240
+ // also normalizes a single-object entry written by an older CLI.
241
+ for (const entry of manifestEntries(value))
242
+ list.push({ machine: m, entry });
168
243
  byAgent.set(sessionId, list);
169
244
  }
170
245
  }
@@ -183,39 +258,85 @@ async function pullAndReconcile(r2, me, opts, result) {
183
258
  result.pullSkipped += candidates - pending.length; // local-owned or unchanged
184
259
  for (const { agentId, sessionId, copies: list, sig } of pending) {
185
260
  const spec = specById(agentId);
186
- // Download each copy (could be a fork across >1 machine), keeping the
187
- // result positionally aligned to `list` null marks a copy we couldn't
188
- // fetch this tick (404 / consistency lag / error).
189
- const fetched = [];
261
+ // A dir-shaped session spans several files; reconcile each file independently
262
+ // (its copies across machines share one relKey). File-shaped sessions have a
263
+ // single group, so this collapses to the original one-file path.
264
+ const byRel = new Map();
190
265
  for (const c of list) {
191
- try {
192
- fetched.push(await r2.get(objectKey(c.machine, agentId, sessionId)));
193
- }
194
- catch (err) {
195
- result.errors.push(`get ${c.machine}/${sessionId}: ${err.message}`);
196
- fetched.push(null);
266
+ const g = byRel.get(c.entry.relKey);
267
+ if (g)
268
+ g.push(c);
269
+ else
270
+ byRel.set(c.entry.relKey, [c]);
271
+ }
272
+ // Resolve every file's write before touching disk. If ANY file's fetch is
273
+ // incomplete (a copy 404s / consistency lag), abandon the WHOLE session this
274
+ // tick — no writes, no pull-state stamp — so it retries intact next time
275
+ // rather than materializing half a session and stamping it done.
276
+ const writes = [];
277
+ let incomplete = false;
278
+ try {
279
+ for (const group of byRel.values()) {
280
+ const fetched = [];
281
+ for (const c of group) {
282
+ try {
283
+ const body = await r2.get(objectKey(c.machine, agentId, sessionId, spec.dirShaped ? c.entry.relKey : undefined));
284
+ // Decrypt before the body reaches the CRDT union — merge + mirror always
285
+ // operate on plaintext. A legacy plaintext object passes through
286
+ // untouched; an envelope without a key throws (surfaced below).
287
+ fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
288
+ }
289
+ catch (err) {
290
+ result.errors.push(`get ${c.machine}/${sessionId}/${c.entry.relKey}: ${err.message}`);
291
+ fetched.push(null);
292
+ }
293
+ }
294
+ const resolved = reconcileCopies(spec, group, fetched);
295
+ if (!resolved) {
296
+ incomplete = true;
297
+ break;
298
+ }
299
+ writes.push(resolved);
197
300
  }
198
301
  }
199
- // null ⇒ an incomplete fetch: skip the write AND the pull-state stamp so we
200
- // retry next tick instead of persisting a partial union / abandoning a branch.
201
- const resolved = reconcileCopies(spec, list, fetched);
202
- if (!resolved)
302
+ catch (err) {
303
+ // `reconcileCopies` -> `mirrorPath` now rejects unsafe peer-controlled
304
+ // machine/relKey (C1 containment). That rejection must stay scoped to this
305
+ // one session: without this catch a single malicious/malformed manifest
306
+ // entry would throw out of the whole `pending` loop, skip the
307
+ // `savePullState` below, and re-throw every tick — a peer-triggered DoS on
308
+ // everyone else's session-sync. Record it and skip; never stamp pull-state
309
+ // for a rejected entry (so nothing marks the bad session "done").
310
+ result.errors.push(`resolve mirror ${agentId}/${sessionId}: ${err.message}`);
203
311
  continue;
204
- const { dest, content, merged } = resolved;
312
+ }
313
+ if (incomplete)
314
+ continue; // retry next tick, session intact
205
315
  try {
206
- let existing = null;
207
- try {
208
- existing = fs.readFileSync(dest, 'utf-8');
316
+ let changed = false;
317
+ let mergedAny = false;
318
+ for (const { dest, content, merged } of writes) {
319
+ let existing = null;
320
+ try {
321
+ existing = fs.readFileSync(dest, 'utf-8');
322
+ }
323
+ catch { /* not present yet */ }
324
+ if (existing !== content) {
325
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
326
+ fs.writeFileSync(dest, content, 'utf-8');
327
+ changed = true;
328
+ if (merged)
329
+ mergedAny = true;
330
+ }
209
331
  }
210
- catch { /* not present yet */ }
211
- if (existing !== content) {
212
- fs.mkdirSync(path.dirname(dest), { recursive: true });
213
- fs.writeFileSync(dest, content, 'utf-8');
214
- if (merged)
332
+ if (changed) {
333
+ if (mergedAny)
215
334
  result.merged++;
216
335
  result.pulled++;
217
336
  if (opts.verbose) {
218
- opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)} <- ${list.map(c => c.machine).join('+')}`);
337
+ const machines = [...new Set(list.map(c => c.machine))].join('+');
338
+ const files = byRel.size > 1 ? ` (${byRel.size} files)` : '';
339
+ opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)}${files} <- ${machines}`);
219
340
  }
220
341
  }
221
342
  else {
@@ -234,6 +355,7 @@ export async function syncSessions(opts = {}) {
234
355
  const cfg = loadR2Config();
235
356
  const me = machineId();
236
357
  const r2 = new R2Client(cfg);
358
+ const encKey = resolveSyncEncKey(cfg); // shared client-side transcript key, or null
237
359
  const result = {
238
360
  machine: me,
239
361
  pushed: 0,
@@ -242,10 +364,11 @@ export async function syncSessions(opts = {}) {
242
364
  merged: 0,
243
365
  pullSkipped: 0,
244
366
  errors: [],
367
+ warnings: [],
245
368
  };
246
369
  if (opts.push !== false)
247
- await pushOwn(r2, me, opts, result);
370
+ await pushOwn(r2, me, encKey, opts, result);
248
371
  if (opts.pull !== false)
249
- await pullAndReconcile(r2, me, opts, result);
372
+ await pullAndReconcile(r2, me, encKey, opts, result);
250
373
  return result;
251
374
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Client-side (zero-knowledge) encryption for session transcripts before they
3
+ * leave this machine for R2.
4
+ *
5
+ * R2 encrypts objects at rest server-side (AES-256, Cloudflare default), but
6
+ * that key is Cloudflare's — anyone with bucket-read access (or Cloudflare
7
+ * itself) can read the plaintext. Transcripts carry secrets, tokens, and
8
+ * absolute file paths, so "encrypted at rest by the provider" is not enough. We
9
+ * seal each transcript BODY client-side with AES-256-GCM under a key that never
10
+ * leaves the machines that share the sync bundle; Cloudflare only ever stores
11
+ * ciphertext.
12
+ *
13
+ * The key is a 32-byte secret (`R2_SYNC_ENC_KEY`) held in the same
14
+ * keychain-backed `r2.backups` bundle as the R2 credentials. Every machine in
15
+ * the sync fabric shares that bundle, so every machine derives the identical key
16
+ * and can decrypt its peers' objects. The key is deliberately SEPARATE from the
17
+ * R2 access key so that rotating the R2 token (RUSH-1464) never orphans
18
+ * transcripts already encrypted under the old one.
19
+ *
20
+ * Identity for CRDT merge stays over PLAINTEXT: the manifest hash is computed on
21
+ * the cleartext transcript (sync.ts), and pull decrypts before the G-Set union
22
+ * (crdt.ts) ever sees the bytes. Ciphertext is non-deterministic (a fresh random
23
+ * IV per seal), so it is never usable as an identity — which is exactly why the
24
+ * manifest, not the object body, carries the hash.
25
+ */
26
+ import type { R2Config } from './config.js';
27
+ /** Serialized envelope stored as the R2 object body when encryption is on. */
28
+ export interface TranscriptEnvelope {
29
+ /** Envelope format version. */
30
+ v: 1;
31
+ alg: 'aes-256-gcm';
32
+ /** base64 12-byte GCM nonce, fresh per object. */
33
+ iv: string;
34
+ /** base64 ciphertext. */
35
+ ct: string;
36
+ /** base64 16-byte GCM auth tag. */
37
+ tag: string;
38
+ }
39
+ /**
40
+ * Decode the configured `R2_SYNC_ENC_KEY` into a 32-byte key, or null when the
41
+ * bundle does not carry one (encryption off — see pushOwn's warning path).
42
+ *
43
+ * Accepts hex (64 chars) or base64; both must decode to exactly 32 bytes. A key
44
+ * that is present but the wrong length THROWS rather than silently truncating —
45
+ * a malformed key is a configuration bug, not a reason to fall back to a weaker
46
+ * or wrong key.
47
+ */
48
+ export declare function resolveSyncEncKey(cfg: Pick<R2Config, 'syncEncKey'>): Buffer | null;
49
+ /** Generate a fresh 32-byte transcript key, base64-encoded (for provisioning). */
50
+ export declare function generateSyncEncKey(): string;
51
+ /** Seal a transcript body. Returns the serialized envelope to store in R2. */
52
+ export declare function encryptTranscript(plaintext: string, key: Buffer): string;
53
+ /**
54
+ * Parse a stored object body into an envelope, or null when it is not one.
55
+ *
56
+ * A plaintext transcript is NDJSON — many JSON objects, one per line — so it
57
+ * never parses as a single object carrying our `v`/`alg`/`ct`/`tag` fields. That
58
+ * makes envelope-vs-plaintext detection unambiguous and lets a puller read BOTH
59
+ * encrypted objects and any legacy plaintext already in the bucket (the beta
60
+ * uploaded plaintext before this landed). This is format-version handling for a
61
+ * real migration, not a "just in case" fallback.
62
+ */
63
+ export declare function parseEnvelope(body: string): TranscriptEnvelope | null;
64
+ /** True when a stored object body is one of our encryption envelopes. */
65
+ export declare function isTranscriptEnvelope(body: string): boolean;
66
+ /** Open a sealed envelope. Throws on a wrong key / tampered body (GCM tag mismatch). */
67
+ export declare function decryptEnvelope(envelope: TranscriptEnvelope, key: Buffer): string;
68
+ /**
69
+ * Return the plaintext transcript for a fetched object body, transparently
70
+ * decrypting when it is an envelope.
71
+ *
72
+ * - Envelope + key → decrypted plaintext.
73
+ * - Envelope + no key → throws (the object is encrypted but this machine has no
74
+ * key to read it — surfacing that beats silently mis-merging ciphertext).
75
+ * - Plaintext body → returned verbatim (legacy/unencrypted object).
76
+ */
77
+ export declare function decryptTranscriptBody(body: string, key: Buffer | null): string;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Client-side (zero-knowledge) encryption for session transcripts before they
3
+ * leave this machine for R2.
4
+ *
5
+ * R2 encrypts objects at rest server-side (AES-256, Cloudflare default), but
6
+ * that key is Cloudflare's — anyone with bucket-read access (or Cloudflare
7
+ * itself) can read the plaintext. Transcripts carry secrets, tokens, and
8
+ * absolute file paths, so "encrypted at rest by the provider" is not enough. We
9
+ * seal each transcript BODY client-side with AES-256-GCM under a key that never
10
+ * leaves the machines that share the sync bundle; Cloudflare only ever stores
11
+ * ciphertext.
12
+ *
13
+ * The key is a 32-byte secret (`R2_SYNC_ENC_KEY`) held in the same
14
+ * keychain-backed `r2.backups` bundle as the R2 credentials. Every machine in
15
+ * the sync fabric shares that bundle, so every machine derives the identical key
16
+ * and can decrypt its peers' objects. The key is deliberately SEPARATE from the
17
+ * R2 access key so that rotating the R2 token (RUSH-1464) never orphans
18
+ * transcripts already encrypted under the old one.
19
+ *
20
+ * Identity for CRDT merge stays over PLAINTEXT: the manifest hash is computed on
21
+ * the cleartext transcript (sync.ts), and pull decrypts before the G-Set union
22
+ * (crdt.ts) ever sees the bytes. Ciphertext is non-deterministic (a fresh random
23
+ * IV per seal), so it is never usable as an identity — which is exactly why the
24
+ * manifest, not the object body, carries the hash.
25
+ */
26
+ import * as crypto from 'crypto';
27
+ const ALG = 'aes-256-gcm';
28
+ const KEY_LEN = 32; // AES-256
29
+ const IV_LEN = 12; // GCM standard nonce
30
+ const TAG_LEN = 16;
31
+ /**
32
+ * Decode the configured `R2_SYNC_ENC_KEY` into a 32-byte key, or null when the
33
+ * bundle does not carry one (encryption off — see pushOwn's warning path).
34
+ *
35
+ * Accepts hex (64 chars) or base64; both must decode to exactly 32 bytes. A key
36
+ * that is present but the wrong length THROWS rather than silently truncating —
37
+ * a malformed key is a configuration bug, not a reason to fall back to a weaker
38
+ * or wrong key.
39
+ */
40
+ export function resolveSyncEncKey(cfg) {
41
+ const raw = cfg.syncEncKey?.trim();
42
+ if (!raw)
43
+ return null;
44
+ let key;
45
+ if (/^[0-9a-f]{64}$/i.test(raw)) {
46
+ key = Buffer.from(raw, 'hex');
47
+ }
48
+ else {
49
+ key = Buffer.from(raw, 'base64');
50
+ }
51
+ if (key.length !== KEY_LEN) {
52
+ throw new Error(`R2_SYNC_ENC_KEY must decode to ${KEY_LEN} bytes (got ${key.length}). ` +
53
+ `Provide 32 random bytes as hex (64 chars) or base64. ` +
54
+ `Generate one with: openssl rand -base64 32`);
55
+ }
56
+ return key;
57
+ }
58
+ /** Generate a fresh 32-byte transcript key, base64-encoded (for provisioning). */
59
+ export function generateSyncEncKey() {
60
+ return crypto.randomBytes(KEY_LEN).toString('base64');
61
+ }
62
+ /** Seal a transcript body. Returns the serialized envelope to store in R2. */
63
+ export function encryptTranscript(plaintext, key) {
64
+ const iv = crypto.randomBytes(IV_LEN);
65
+ const cipher = crypto.createCipheriv(ALG, key, iv);
66
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
67
+ const tag = cipher.getAuthTag();
68
+ const envelope = {
69
+ v: 1,
70
+ alg: ALG,
71
+ iv: iv.toString('base64'),
72
+ ct: ct.toString('base64'),
73
+ tag: tag.toString('base64'),
74
+ };
75
+ return JSON.stringify(envelope);
76
+ }
77
+ /**
78
+ * Parse a stored object body into an envelope, or null when it is not one.
79
+ *
80
+ * A plaintext transcript is NDJSON — many JSON objects, one per line — so it
81
+ * never parses as a single object carrying our `v`/`alg`/`ct`/`tag` fields. That
82
+ * makes envelope-vs-plaintext detection unambiguous and lets a puller read BOTH
83
+ * encrypted objects and any legacy plaintext already in the bucket (the beta
84
+ * uploaded plaintext before this landed). This is format-version handling for a
85
+ * real migration, not a "just in case" fallback.
86
+ */
87
+ export function parseEnvelope(body) {
88
+ const trimmed = body.trimStart();
89
+ if (!trimmed.startsWith('{'))
90
+ return null; // NDJSON first line is an object too, but…
91
+ let obj;
92
+ try {
93
+ obj = JSON.parse(body); // …the WHOLE body must be one JSON value to be an envelope
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ if (obj && typeof obj === 'object' &&
99
+ obj.v === 1 &&
100
+ obj.alg === ALG &&
101
+ typeof obj.iv === 'string' &&
102
+ typeof obj.ct === 'string' &&
103
+ typeof obj.tag === 'string') {
104
+ return obj;
105
+ }
106
+ return null;
107
+ }
108
+ /** True when a stored object body is one of our encryption envelopes. */
109
+ export function isTranscriptEnvelope(body) {
110
+ return parseEnvelope(body) !== null;
111
+ }
112
+ /** Open a sealed envelope. Throws on a wrong key / tampered body (GCM tag mismatch). */
113
+ export function decryptEnvelope(envelope, key) {
114
+ const iv = Buffer.from(envelope.iv, 'base64');
115
+ const ct = Buffer.from(envelope.ct, 'base64');
116
+ const tag = Buffer.from(envelope.tag, 'base64');
117
+ if (tag.length !== TAG_LEN) {
118
+ throw new Error(`Transcript envelope has a malformed auth tag (${tag.length} bytes).`);
119
+ }
120
+ const decipher = crypto.createDecipheriv(ALG, key, iv);
121
+ decipher.setAuthTag(tag);
122
+ try {
123
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf-8');
124
+ }
125
+ catch {
126
+ throw new Error('Transcript decryption failed — wrong R2_SYNC_ENC_KEY or corrupt object.');
127
+ }
128
+ }
129
+ /**
130
+ * Return the plaintext transcript for a fetched object body, transparently
131
+ * decrypting when it is an envelope.
132
+ *
133
+ * - Envelope + key → decrypted plaintext.
134
+ * - Envelope + no key → throws (the object is encrypted but this machine has no
135
+ * key to read it — surfacing that beats silently mis-merging ciphertext).
136
+ * - Plaintext body → returned verbatim (legacy/unencrypted object).
137
+ */
138
+ export function decryptTranscriptBody(body, key) {
139
+ const envelope = parseEnvelope(body);
140
+ if (!envelope)
141
+ return body; // legacy plaintext object
142
+ if (!key) {
143
+ throw new Error('Fetched an encrypted transcript but R2_SYNC_ENC_KEY is not set in the r2.backups bundle. ' +
144
+ 'Add the shared key so this machine can decrypt peers\' sessions.');
145
+ }
146
+ return decryptEnvelope(envelope, key);
147
+ }
@@ -24,7 +24,7 @@ import { fileURLToPath } from 'url';
24
24
  import { randomBytes, createHash } from 'crypto';
25
25
  import { Readable } from 'stream';
26
26
  import { pipeline } from 'stream/promises';
27
- import { sshExec, SSH_OPTS } from './ssh-exec.js';
27
+ import { sshExec, SSH_OPTS, assertValidSshTarget } from './ssh-exec.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { encodePowerShell } from './browser/drivers/ssh.js';
30
30
  import { getDevice } from './devices/registry.js';
@@ -57,6 +57,18 @@ export function buildTunnelArgs(user, host, localPort, remotePort) {
57
57
  */
58
58
  export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
59
59
  return new Promise((resolve, reject) => {
60
+ // `user`/`host` can originate from a browser ssh:// profile or a device
61
+ // record. buildTunnelArgs places `${user}@${host}` before `-N`/SSH_OPTS, so
62
+ // a `-`-leading user would be parsed as an ssh option flag (option
63
+ // injection). Validate at the spawn sink so every caller is covered; reject
64
+ // (rather than throw synchronously) to keep the Promise contract.
65
+ try {
66
+ assertValidSshTarget(`${user}@${host}`);
67
+ }
68
+ catch (err) {
69
+ reject(err);
70
+ return;
71
+ }
60
72
  const args = buildTunnelArgs(user, host, localPort, remotePort);
61
73
  const tunnel = spawn('ssh', args, {
62
74
  stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Subagents detector. The installed-name enumeration for every agent's on-disk
3
+ * layout is declared once in the subagent registry; this detector is generic
4
+ * and delegates to `listInstalledSubagentNames` instead of a per-agent builder.
5
+ */
1
6
  import type { AgentId } from '../../types.js';
2
7
  import type { ResourceDetector } from './types.js';
3
8
  export declare const subagentsDetectors: Partial<Record<AgentId, ResourceDetector>>;