@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +51 -1
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +100 -28
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/commands.js +29 -0
  32. package/dist/lib/convert.d.ts +11 -0
  33. package/dist/lib/convert.js +22 -0
  34. package/dist/lib/daemon.js +2 -0
  35. package/dist/lib/devices/fleet.d.ts +62 -0
  36. package/dist/lib/devices/fleet.js +128 -0
  37. package/dist/lib/doctor-diff.js +17 -1
  38. package/dist/lib/funnel.d.ts +5 -0
  39. package/dist/lib/funnel.js +23 -0
  40. package/dist/lib/git.d.ts +21 -5
  41. package/dist/lib/git.js +64 -14
  42. package/dist/lib/goose-commands.d.ts +41 -0
  43. package/dist/lib/goose-commands.js +176 -0
  44. package/dist/lib/hooks.js +134 -0
  45. package/dist/lib/hosts/credentials.d.ts +28 -0
  46. package/dist/lib/hosts/credentials.js +48 -0
  47. package/dist/lib/hosts/dispatch.d.ts +25 -0
  48. package/dist/lib/hosts/dispatch.js +68 -2
  49. package/dist/lib/hosts/passthrough.d.ts +13 -10
  50. package/dist/lib/hosts/passthrough.js +119 -29
  51. package/dist/lib/mailbox-gc.js +4 -16
  52. package/dist/lib/migrate.d.ts +12 -0
  53. package/dist/lib/migrate.js +55 -1
  54. package/dist/lib/permissions.d.ts +15 -0
  55. package/dist/lib/permissions.js +99 -0
  56. package/dist/lib/plugins.d.ts +20 -0
  57. package/dist/lib/plugins.js +118 -0
  58. package/dist/lib/resources/permissions.js +2 -0
  59. package/dist/lib/routines.d.ts +29 -10
  60. package/dist/lib/routines.js +47 -15
  61. package/dist/lib/session/active.d.ts +8 -1
  62. package/dist/lib/session/active.js +1 -0
  63. package/dist/lib/session/parse.js +12 -0
  64. package/dist/lib/session/state.d.ts +33 -0
  65. package/dist/lib/session/state.js +40 -0
  66. package/dist/lib/session/sync/agents.d.ts +2 -0
  67. package/dist/lib/session/sync/agents.js +39 -1
  68. package/dist/lib/session/sync/config.d.ts +8 -0
  69. package/dist/lib/session/sync/config.js +6 -1
  70. package/dist/lib/session/sync/provision.d.ts +49 -0
  71. package/dist/lib/session/sync/provision.js +91 -0
  72. package/dist/lib/session/sync/sync.d.ts +3 -0
  73. package/dist/lib/session/sync/sync.js +26 -6
  74. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  75. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  76. package/dist/lib/staleness/detectors/permissions.js +23 -0
  77. package/dist/lib/staleness/detectors/subagents.js +36 -0
  78. package/dist/lib/staleness/writers/commands.js +7 -0
  79. package/dist/lib/staleness/writers/hooks.js +1 -1
  80. package/dist/lib/staleness/writers/subagents.js +22 -3
  81. package/dist/lib/startup/command-registry.d.ts +2 -0
  82. package/dist/lib/startup/command-registry.js +11 -0
  83. package/dist/lib/state.d.ts +10 -2
  84. package/dist/lib/state.js +14 -2
  85. package/dist/lib/subagents.d.ts +32 -0
  86. package/dist/lib/subagents.js +238 -0
  87. package/dist/lib/triggers/webhook.d.ts +70 -27
  88. package/dist/lib/triggers/webhook.js +264 -43
  89. 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), content, 'application/x-ndjson');
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
- fetched.push(await r2.get(objectKey(c.machine, agentId, sessionId)));
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/`. OpenClaw copies the full subagent
7
- * directory (with AGENT.md renamed to AGENTS.md) into
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