@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.
- package/CHANGELOG.md +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- 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 {
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
236
|
+
for (const [sessionId, value] of Object.entries(sessions)) {
|
|
166
237
|
const list = byAgent.get(sessionId) ?? [];
|
|
167
|
-
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
const
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
312
|
+
}
|
|
313
|
+
if (incomplete)
|
|
314
|
+
continue; // retry next tick, session intact
|
|
205
315
|
try {
|
|
206
|
-
let
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -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>>;
|