@phnx-labs/agents-cli 1.20.61 → 1.20.63
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 +57 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +51 -1
- 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/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +73 -16
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +8 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +72 -2
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +6 -2
- package/dist/lib/agents.d.ts +26 -0
- package/dist/lib/agents.js +100 -28
- 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/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/doctor-diff.js +17 -1
- 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/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- 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/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/session/sync/agents.d.ts +2 -0
- package/dist/lib/session/sync/agents.js +39 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- 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 +3 -0
- package/dist/lib/session/sync/sync.js +26 -6
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +11 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/package.json +1 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provisioning for cross-machine session sync: write the `r2.backups` secrets
|
|
3
|
+
* bundle and probe R2 connectivity. Pure logic — no prompts, no Commander — so
|
|
4
|
+
* it is unit-testable against the in-memory keychain seam. The interactive flow
|
|
5
|
+
* that collects the values lives in the command layer
|
|
6
|
+
* (`commands/sync-provision.ts`).
|
|
7
|
+
*/
|
|
8
|
+
/** Values a caller has already collected for the sync bundle. */
|
|
9
|
+
export interface ProvisionInput {
|
|
10
|
+
accountId: string;
|
|
11
|
+
bucketName: string;
|
|
12
|
+
accessKeyId: string;
|
|
13
|
+
secretAccessKey: string;
|
|
14
|
+
/** Optional S3 endpoint override (MinIO / non-R2). Default derived from accountId. */
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Shared transcript-encryption key for a machine JOINING an existing fabric —
|
|
18
|
+
* paste the key the first machine generated. Omit on the first machine to mint
|
|
19
|
+
* a fresh one. Ignored if the bundle already carries a key (never overwritten).
|
|
20
|
+
*/
|
|
21
|
+
encKey?: string;
|
|
22
|
+
}
|
|
23
|
+
export type EncKeyAction = 'generated' | 'reused' | 'provided';
|
|
24
|
+
/**
|
|
25
|
+
* Create or update the `r2.backups` bundle from already-collected values.
|
|
26
|
+
*
|
|
27
|
+
* The four R2 credentials are always (over)written. The encryption key is
|
|
28
|
+
* handled carefully: an existing key is REUSED, never overwritten (overwriting
|
|
29
|
+
* would orphan every transcript peers already encrypted under it); otherwise a
|
|
30
|
+
* caller-supplied `encKey` is stored, or a fresh one minted on the first machine.
|
|
31
|
+
*/
|
|
32
|
+
export declare function writeSyncBundle(input: ProvisionInput): {
|
|
33
|
+
encKeyAction: EncKeyAction;
|
|
34
|
+
};
|
|
35
|
+
/** The shared encryption key currently stored (for display when handing it to peers). */
|
|
36
|
+
export declare function readStoredEncKey(): string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Prove the configured bundle can read AND write its bucket: put a throwaway
|
|
39
|
+
* object, read it back, delete it. Returns a structured result instead of
|
|
40
|
+
* throwing so the caller (setup) never crashes on a bad credential. The probe
|
|
41
|
+
* key lives OUTSIDE the `sessions/` prefix so it can never be mistaken for a
|
|
42
|
+
* machine manifest by the pull path.
|
|
43
|
+
*/
|
|
44
|
+
export declare function probeR2Connectivity(): Promise<{
|
|
45
|
+
ok: true;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
error: string;
|
|
49
|
+
}>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provisioning for cross-machine session sync: write the `r2.backups` secrets
|
|
3
|
+
* bundle and probe R2 connectivity. Pure logic — no prompts, no Commander — so
|
|
4
|
+
* it is unit-testable against the in-memory keychain seam. The interactive flow
|
|
5
|
+
* that collects the values lives in the command layer
|
|
6
|
+
* (`commands/sync-provision.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import { bundleExists, readBundle, writeBundle, bundleItemStore, keychainRef, } from '../../secrets/bundles.js';
|
|
9
|
+
import { secretsKeychainItem } from '../../secrets/index.js';
|
|
10
|
+
import { SYNC_BUNDLE, loadR2Config, clearR2ConfigCache } from './config.js';
|
|
11
|
+
import { R2Client } from './r2.js';
|
|
12
|
+
import { generateSyncEncKey, resolveSyncEncKey } from './transcript-crypto.js';
|
|
13
|
+
import { machineId } from '../../machine-id.js';
|
|
14
|
+
/**
|
|
15
|
+
* Create or update the `r2.backups` bundle from already-collected values.
|
|
16
|
+
*
|
|
17
|
+
* The four R2 credentials are always (over)written. The encryption key is
|
|
18
|
+
* handled carefully: an existing key is REUSED, never overwritten (overwriting
|
|
19
|
+
* would orphan every transcript peers already encrypted under it); otherwise a
|
|
20
|
+
* caller-supplied `encKey` is stored, or a fresh one minted on the first machine.
|
|
21
|
+
*/
|
|
22
|
+
export function writeSyncBundle(input) {
|
|
23
|
+
// Validate a joining machine's pasted key up front so a bad paste fails here,
|
|
24
|
+
// not silently at the next sync.
|
|
25
|
+
if (input.encKey)
|
|
26
|
+
resolveSyncEncKey({ syncEncKey: input.encKey });
|
|
27
|
+
const bundle = bundleExists(SYNC_BUNDLE)
|
|
28
|
+
? readBundle(SYNC_BUNDLE)
|
|
29
|
+
: { name: SYNC_BUNDLE, description: 'Cross-machine session-sync R2 credentials', vars: {} };
|
|
30
|
+
const store = bundleItemStore(bundle.backend);
|
|
31
|
+
const setKey = (key, value) => {
|
|
32
|
+
store.set(secretsKeychainItem(SYNC_BUNDLE, key), value);
|
|
33
|
+
bundle.vars[key] = keychainRef(key);
|
|
34
|
+
};
|
|
35
|
+
setKey('R2_ACCOUNT_ID', input.accountId);
|
|
36
|
+
setKey('R2_BUCKET_NAME', input.bucketName);
|
|
37
|
+
setKey('R2_ACCESS_KEY_ID', input.accessKeyId);
|
|
38
|
+
setKey('R2_SECRET_ACCESS_KEY', input.secretAccessKey);
|
|
39
|
+
if (input.endpoint)
|
|
40
|
+
setKey('R2_ENDPOINT', input.endpoint);
|
|
41
|
+
let encKeyAction;
|
|
42
|
+
if (bundle.vars['R2_SYNC_ENC_KEY']) {
|
|
43
|
+
encKeyAction = 'reused';
|
|
44
|
+
}
|
|
45
|
+
else if (input.encKey) {
|
|
46
|
+
setKey('R2_SYNC_ENC_KEY', input.encKey);
|
|
47
|
+
encKeyAction = 'provided';
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
setKey('R2_SYNC_ENC_KEY', generateSyncEncKey());
|
|
51
|
+
encKeyAction = 'generated';
|
|
52
|
+
}
|
|
53
|
+
writeBundle(bundle);
|
|
54
|
+
clearR2ConfigCache(); // so the next loadR2Config() sees the just-written values
|
|
55
|
+
return { encKeyAction };
|
|
56
|
+
}
|
|
57
|
+
/** The shared encryption key currently stored (for display when handing it to peers). */
|
|
58
|
+
export function readStoredEncKey() {
|
|
59
|
+
clearR2ConfigCache();
|
|
60
|
+
try {
|
|
61
|
+
return loadR2Config().syncEncKey ?? null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Prove the configured bundle can read AND write its bucket: put a throwaway
|
|
69
|
+
* object, read it back, delete it. Returns a structured result instead of
|
|
70
|
+
* throwing so the caller (setup) never crashes on a bad credential. The probe
|
|
71
|
+
* key lives OUTSIDE the `sessions/` prefix so it can never be mistaken for a
|
|
72
|
+
* machine manifest by the pull path.
|
|
73
|
+
*/
|
|
74
|
+
export async function probeR2Connectivity() {
|
|
75
|
+
try {
|
|
76
|
+
clearR2ConfigCache();
|
|
77
|
+
const cfg = loadR2Config();
|
|
78
|
+
const r2 = new R2Client(cfg);
|
|
79
|
+
const key = `.agents-provision-probe/${machineId()}.txt`;
|
|
80
|
+
const token = `agents-sync-probe-${machineId()}`;
|
|
81
|
+
await r2.put(key, token, 'text/plain');
|
|
82
|
+
const got = await r2.get(key);
|
|
83
|
+
await r2.delete(key);
|
|
84
|
+
if (got !== token)
|
|
85
|
+
return { ok: false, error: 'wrote a probe object but read back different bytes' };
|
|
86
|
+
return { ok: true };
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return { ok: false, error: err.message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -24,6 +24,9 @@ export interface SyncResult {
|
|
|
24
24
|
merged: number;
|
|
25
25
|
pullSkipped: number;
|
|
26
26
|
errors: string[];
|
|
27
|
+
/** Non-fatal advisories (e.g. transcripts uploaded unencrypted). Unlike
|
|
28
|
+
* `errors` these do not set a failing exit code. */
|
|
29
|
+
warnings: string[];
|
|
27
30
|
}
|
|
28
31
|
export interface SyncOptions {
|
|
29
32
|
verbose?: boolean;
|
|
@@ -20,16 +20,25 @@ import { R2Client } from './r2.js';
|
|
|
20
20
|
import { loadR2Config, machineId } from './config.js';
|
|
21
21
|
import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, SESSIONS_PREFIX, } from './agents.js';
|
|
22
22
|
import { mergeTranscripts, transcriptStats } from './crdt.js';
|
|
23
|
+
import { resolveSyncEncKey, encryptTranscript, decryptTranscriptBody } from './transcript-crypto.js';
|
|
23
24
|
import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, } 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)) {
|
|
@@ -53,11 +62,16 @@ async function pushOwn(r2, me, opts, result) {
|
|
|
53
62
|
catch {
|
|
54
63
|
continue;
|
|
55
64
|
}
|
|
65
|
+
// Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
|
|
66
|
+
// non-deterministic), so hash + manifest are computed on cleartext; only
|
|
67
|
+
// the stored object body is sealed.
|
|
56
68
|
const hash = hashContent(content);
|
|
57
69
|
const { lastTs } = transcriptStats(content);
|
|
58
70
|
const entry = { relKey: t.relKey, size: stat.size, hash, lastTs };
|
|
71
|
+
const body = encKey ? encryptTranscript(content, encKey) : content;
|
|
72
|
+
const contentType = encKey ? 'application/json' : 'application/x-ndjson';
|
|
59
73
|
try {
|
|
60
|
-
await r2.put(objectKey(me, spec.id, t.sessionId),
|
|
74
|
+
await r2.put(objectKey(me, spec.id, t.sessionId), body, contentType);
|
|
61
75
|
ledgerRecord(ledger, t.absPath, stat.size, stat.mtimeMs, hash);
|
|
62
76
|
agentManifest[t.sessionId] = entry;
|
|
63
77
|
result.pushed++;
|
|
@@ -138,7 +152,7 @@ export function reconcileCopies(spec, copies, fetched) {
|
|
|
138
152
|
return resolveMirrorWrite(spec, copies, contents);
|
|
139
153
|
}
|
|
140
154
|
/** Fetch other machines' manifests, union changed sessions into the mirror. */
|
|
141
|
-
async function pullAndReconcile(r2, me, opts, result) {
|
|
155
|
+
async function pullAndReconcile(r2, me, encKey, opts, result) {
|
|
142
156
|
const prefixes = await r2.listPrefixes(SESSIONS_PREFIX); // sessions/<machine>/
|
|
143
157
|
const machines = prefixes
|
|
144
158
|
.map(p => p.slice(SESSIONS_PREFIX.length).replace(/\/$/, ''))
|
|
@@ -189,7 +203,11 @@ async function pullAndReconcile(r2, me, opts, result) {
|
|
|
189
203
|
const fetched = [];
|
|
190
204
|
for (const c of list) {
|
|
191
205
|
try {
|
|
192
|
-
|
|
206
|
+
const body = await r2.get(objectKey(c.machine, agentId, sessionId));
|
|
207
|
+
// Decrypt before the body reaches the CRDT union — the merge and the
|
|
208
|
+
// mirror always operate on plaintext. A legacy plaintext object passes
|
|
209
|
+
// through untouched; an envelope without a key throws (surfaced below).
|
|
210
|
+
fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
|
|
193
211
|
}
|
|
194
212
|
catch (err) {
|
|
195
213
|
result.errors.push(`get ${c.machine}/${sessionId}: ${err.message}`);
|
|
@@ -234,6 +252,7 @@ export async function syncSessions(opts = {}) {
|
|
|
234
252
|
const cfg = loadR2Config();
|
|
235
253
|
const me = machineId();
|
|
236
254
|
const r2 = new R2Client(cfg);
|
|
255
|
+
const encKey = resolveSyncEncKey(cfg); // shared client-side transcript key, or null
|
|
237
256
|
const result = {
|
|
238
257
|
machine: me,
|
|
239
258
|
pushed: 0,
|
|
@@ -242,10 +261,11 @@ export async function syncSessions(opts = {}) {
|
|
|
242
261
|
merged: 0,
|
|
243
262
|
pullSkipped: 0,
|
|
244
263
|
errors: [],
|
|
264
|
+
warnings: [],
|
|
245
265
|
};
|
|
246
266
|
if (opts.push !== false)
|
|
247
|
-
await pushOwn(r2, me, opts, result);
|
|
267
|
+
await pushOwn(r2, me, encKey, opts, result);
|
|
248
268
|
if (opts.pull !== false)
|
|
249
|
-
await pullAndReconcile(r2, me, opts, result);
|
|
269
|
+
await pullAndReconcile(r2, me, encKey, opts, result);
|
|
250
270
|
return result;
|
|
251
271
|
}
|
|
@@ -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
|
+
}
|
|
@@ -266,6 +266,28 @@ function buildKiroDetector() {
|
|
|
266
266
|
},
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
|
+
function buildOpenClawDetector() {
|
|
270
|
+
return {
|
|
271
|
+
kind: 'permissions',
|
|
272
|
+
agent: 'openclaw',
|
|
273
|
+
list({ versionHome }) {
|
|
274
|
+
const configPath = path.join(versionHome, '.openclaw', 'openclaw.json');
|
|
275
|
+
if (!fs.existsSync(configPath))
|
|
276
|
+
return [];
|
|
277
|
+
try {
|
|
278
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
279
|
+
const tools = config.tools;
|
|
280
|
+
const hasAllow = Array.isArray(tools?.alsoAllow) && tools.alsoAllow.length > 0;
|
|
281
|
+
const hasDeny = Array.isArray(tools?.deny) && tools.deny.length > 0;
|
|
282
|
+
if (hasAllow || hasDeny) {
|
|
283
|
+
return discoverPermissionGroups().map(g => g.name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch { /* parse fail */ }
|
|
287
|
+
return [];
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
269
291
|
const handlers = {
|
|
270
292
|
claude: buildClaudeDetector,
|
|
271
293
|
codex: buildCodexDetector,
|
|
@@ -278,6 +300,7 @@ const handlers = {
|
|
|
278
300
|
cursor: buildCursorDetector,
|
|
279
301
|
droid: buildDroidDetector,
|
|
280
302
|
kiro: buildKiroDetector,
|
|
303
|
+
openclaw: buildOpenClawDetector,
|
|
281
304
|
};
|
|
282
305
|
export const permissionsDetectors = lazyAgentMap(() => {
|
|
283
306
|
const m = {};
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Subagents detector. Claude/Gemini/Grok: flat .md files under `<agentDir>/agents/`.
|
|
3
3
|
* Codex: flat .toml files under `<versionHome>/.codex/agents/`.
|
|
4
4
|
* Droid: flat .md files under `<versionHome>/.factory/droids/`.
|
|
5
|
+
* Cursor: flat .md files under `<versionHome>/.cursor/agents/`.
|
|
6
|
+
* ForgeCode: flat .md files under `<versionHome>/.forge/agents/`.
|
|
5
7
|
* OpenClaw: subdirectories containing AGENTS.md under `<versionHome>/.openclaw/`.
|
|
6
8
|
* Mirrors versions.ts:521-539.
|
|
7
9
|
*/
|
|
@@ -74,6 +76,20 @@ function buildCopilotDetector() {
|
|
|
74
76
|
},
|
|
75
77
|
};
|
|
76
78
|
}
|
|
79
|
+
function buildCursorDetector() {
|
|
80
|
+
return {
|
|
81
|
+
kind: 'subagents',
|
|
82
|
+
agent: 'cursor',
|
|
83
|
+
list({ versionHome }) {
|
|
84
|
+
const agentsDir = path.join(versionHome, '.cursor', 'agents');
|
|
85
|
+
if (!fs.existsSync(agentsDir))
|
|
86
|
+
return [];
|
|
87
|
+
return fs.readdirSync(agentsDir)
|
|
88
|
+
.filter(f => f.endsWith('.md'))
|
|
89
|
+
.map(f => f.replace(/\.md$/, ''));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
77
93
|
function buildOpenclawDetector() {
|
|
78
94
|
return {
|
|
79
95
|
kind: 'subagents',
|
|
@@ -117,6 +133,23 @@ function buildKiroDetector() {
|
|
|
117
133
|
},
|
|
118
134
|
};
|
|
119
135
|
}
|
|
136
|
+
function buildForgeDetector() {
|
|
137
|
+
return buildFlatMdAgentsDetector('forge', '.forge');
|
|
138
|
+
}
|
|
139
|
+
function buildGooseDetector() {
|
|
140
|
+
return {
|
|
141
|
+
kind: 'subagents',
|
|
142
|
+
agent: 'goose',
|
|
143
|
+
list({ versionHome }) {
|
|
144
|
+
const agentsDir = path.join(versionHome, '.config', 'goose', 'agents');
|
|
145
|
+
if (!fs.existsSync(agentsDir))
|
|
146
|
+
return [];
|
|
147
|
+
return fs.readdirSync(agentsDir)
|
|
148
|
+
.filter(f => f.endsWith('.yaml'))
|
|
149
|
+
.map(f => f.replace(/\.yaml$/, ''));
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
120
153
|
function buildOpenCodeDetector() {
|
|
121
154
|
return {
|
|
122
155
|
kind: 'subagents',
|
|
@@ -157,6 +190,9 @@ const handlers = {
|
|
|
157
190
|
droid: buildDroidDetector,
|
|
158
191
|
openclaw: buildOpenclawDetector,
|
|
159
192
|
kiro: buildKiroDetector,
|
|
193
|
+
cursor: buildCursorDetector,
|
|
194
|
+
forge: buildForgeDetector,
|
|
195
|
+
goose: buildGooseDetector,
|
|
160
196
|
};
|
|
161
197
|
export const subagentsDetectors = lazyAgentMap(() => {
|
|
162
198
|
const m = {};
|
|
@@ -25,6 +25,7 @@ import { safeJoin } from '../../paths.js';
|
|
|
25
25
|
import { markdownToToml } from '../../convert.js';
|
|
26
26
|
import { commandAppliesTo, parseCommandMetadata } from '../../commands.js';
|
|
27
27
|
import { installCommandSkillToVersion, shouldInstallCommandAsSkill } from '../../command-skills.js';
|
|
28
|
+
import { installGooseCommandToVersion } from '../../goose-commands.js';
|
|
28
29
|
import { resolveCommandSource, trustedSkillRoots } from './sources.js';
|
|
29
30
|
import { lazyAgentMap } from './lazy-map.js';
|
|
30
31
|
function buildCommandsWriter(agent) {
|
|
@@ -59,6 +60,12 @@ function buildCommandsWriter(agent) {
|
|
|
59
60
|
if (!installed.success)
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
63
|
+
else if (agent === 'goose') {
|
|
64
|
+
// Goose: recipe YAML + config.yaml slash_commands entry, not a file copy.
|
|
65
|
+
const installed = installGooseCommandToVersion(versionHome, cmd, srcFile);
|
|
66
|
+
if (!installed.success)
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
62
69
|
else if (agentConfig.format === 'toml') {
|
|
63
70
|
const content = fs.readFileSync(srcFile, 'utf-8');
|
|
64
71
|
const tomlContent = markdownToToml(cmd, content);
|
|
@@ -36,7 +36,7 @@ function buildHooksWriter(agent) {
|
|
|
36
36
|
// registerHooksForGrok — file copy alone only sees top-level available.hooks
|
|
37
37
|
// names (RUSH-1353). Copilot/Kiro/Goose load managed *.json under their
|
|
38
38
|
// hooks dirs the same way.
|
|
39
|
-
if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'kiro' || agent === 'goose' || agent === 'cursor' || agent === 'grok') {
|
|
39
|
+
if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid' || agent === 'copilot' || agent === 'kiro' || agent === 'goose' || agent === 'cursor' || agent === 'grok' || agent === 'hermes') {
|
|
40
40
|
registerHooksToSettings(agent, versionHome);
|
|
41
41
|
}
|
|
42
42
|
return { synced };
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* .md file under their native agents directory. Codex writes TOML under
|
|
4
4
|
* `.codex/agents/`.
|
|
5
5
|
* Droid (Factory AI) flattens each into a custom droid .md under
|
|
6
|
-
* `<versionHome>/.factory/droids/`.
|
|
7
|
-
*
|
|
6
|
+
* `<versionHome>/.factory/droids/`. Cursor flattens each into a custom
|
|
7
|
+
* subagent .md under `<versionHome>/.cursor/agents/`. OpenClaw copies the
|
|
8
|
+
* full subagent directory (with AGENT.md renamed to AGENTS.md) into
|
|
8
9
|
* `<versionHome>/.openclaw/<name>/`.
|
|
9
10
|
*
|
|
10
11
|
* Source-side discovery is `listInstalledSubagents` from lib/subagents.ts —
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
import * as fs from 'fs';
|
|
15
16
|
import * as path from 'path';
|
|
16
17
|
import { capableAgents } from '../../capabilities.js';
|
|
17
|
-
import { listInstalledSubagents, transformSubagentForClaude, transformSubagentForCodex, transformSubagentForCopilot, writeKimiSubagentFiles, buildKimiSubagentsParentYaml, KIMI_SUBAGENTS_PARENT_FILE, transformSubagentForOpenCode, transformSubagentForAntigravity, transformSubagentForDroid, transformSubagentForKiro, syncSubagentToOpenclaw, parseSubagentFrontmatter, } from '../../subagents.js';
|
|
18
|
+
import { listInstalledSubagents, transformSubagentForClaude, transformSubagentForCodex, transformSubagentForCopilot, writeKimiSubagentFiles, buildKimiSubagentsParentYaml, KIMI_SUBAGENTS_PARENT_FILE, transformSubagentForOpenCode, transformSubagentForAntigravity, transformSubagentForDroid, transformSubagentForForge, transformSubagentForKiro, transformSubagentForCursor, transformSubagentForGoose, syncSubagentToOpenclaw, parseSubagentFrontmatter, } from '../../subagents.js';
|
|
18
19
|
import { safeJoin } from '../../paths.js';
|
|
19
20
|
import { lazyAgentMap } from './lazy-map.js';
|
|
20
21
|
function buildSubagentsWriter(agent) {
|
|
@@ -83,6 +84,24 @@ function buildSubagentsWriter(agent) {
|
|
|
83
84
|
fs.writeFileSync(safeJoin(agentsDir, `${sub.name}.json`), transformSubagentForKiro(sub.path));
|
|
84
85
|
synced.push(sub.name);
|
|
85
86
|
}
|
|
87
|
+
else if (agent === 'cursor') {
|
|
88
|
+
const agentsDir = path.join(versionHome, '.cursor', 'agents');
|
|
89
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
90
|
+
fs.writeFileSync(safeJoin(agentsDir, `${sub.name}.md`), transformSubagentForCursor(sub.path));
|
|
91
|
+
synced.push(sub.name);
|
|
92
|
+
}
|
|
93
|
+
else if (agent === 'forge') {
|
|
94
|
+
const agentsDir = path.join(versionHome, '.forge', 'agents');
|
|
95
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
96
|
+
fs.writeFileSync(safeJoin(agentsDir, `${sub.name}.md`), transformSubagentForForge(sub.path));
|
|
97
|
+
synced.push(sub.name);
|
|
98
|
+
}
|
|
99
|
+
else if (agent === 'goose') {
|
|
100
|
+
const agentsDir = path.join(versionHome, '.config', 'goose', 'agents');
|
|
101
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
102
|
+
fs.writeFileSync(safeJoin(agentsDir, `${sub.name}.yaml`), transformSubagentForGoose(sub.path));
|
|
103
|
+
synced.push(sub.name);
|
|
104
|
+
}
|
|
86
105
|
}
|
|
87
106
|
catch { /* per-item sync failure: skip */ }
|
|
88
107
|
}
|
|
@@ -88,6 +88,8 @@ export declare const loadMessage: ModuleLoader;
|
|
|
88
88
|
export declare const loadFeed: ModuleLoader;
|
|
89
89
|
export declare const loadServe: ModuleLoader;
|
|
90
90
|
export declare const loadAudit: ModuleLoader;
|
|
91
|
+
export declare const loadWebhook: ModuleLoader;
|
|
92
|
+
export declare const loadFunnel: ModuleLoader;
|
|
91
93
|
/**
|
|
92
94
|
* Commands whose modules pull in the SQLite-backed session/cloud stack. They are
|
|
93
95
|
* registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
|