@phnx-labs/agents-cli 1.20.62 → 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 (65) hide show
  1. package/CHANGELOG.md +45 -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 +41 -0
  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 +58 -18
  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/daemon.js +2 -0
  32. package/dist/lib/devices/fleet.d.ts +62 -0
  33. package/dist/lib/devices/fleet.js +128 -0
  34. package/dist/lib/funnel.d.ts +5 -0
  35. package/dist/lib/funnel.js +23 -0
  36. package/dist/lib/git.d.ts +21 -5
  37. package/dist/lib/git.js +64 -14
  38. package/dist/lib/hosts/credentials.d.ts +28 -0
  39. package/dist/lib/hosts/credentials.js +48 -0
  40. package/dist/lib/hosts/dispatch.d.ts +25 -0
  41. package/dist/lib/hosts/dispatch.js +68 -2
  42. package/dist/lib/hosts/passthrough.d.ts +13 -10
  43. package/dist/lib/hosts/passthrough.js +119 -29
  44. package/dist/lib/mailbox-gc.js +4 -16
  45. package/dist/lib/migrate.d.ts +12 -0
  46. package/dist/lib/migrate.js +55 -1
  47. package/dist/lib/routines.d.ts +29 -10
  48. package/dist/lib/routines.js +47 -15
  49. package/dist/lib/session/sync/agents.d.ts +2 -0
  50. package/dist/lib/session/sync/agents.js +39 -1
  51. package/dist/lib/session/sync/config.d.ts +8 -0
  52. package/dist/lib/session/sync/config.js +6 -1
  53. package/dist/lib/session/sync/provision.d.ts +49 -0
  54. package/dist/lib/session/sync/provision.js +91 -0
  55. package/dist/lib/session/sync/sync.d.ts +3 -0
  56. package/dist/lib/session/sync/sync.js +26 -6
  57. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  58. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  59. package/dist/lib/startup/command-registry.d.ts +2 -0
  60. package/dist/lib/startup/command-registry.js +11 -0
  61. package/dist/lib/state.d.ts +10 -2
  62. package/dist/lib/state.js +14 -2
  63. package/dist/lib/triggers/webhook.d.ts +70 -27
  64. package/dist/lib/triggers/webhook.js +264 -43
  65. 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
+ }
@@ -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
@@ -66,6 +66,8 @@ export const loadMessage = async () => (await import('../../commands/message.js'
66
66
  export const loadFeed = async () => (await import('../../commands/feed.js')).registerFeedCommand;
67
67
  export const loadServe = async () => (await import('../../commands/serve.js')).registerServeCommand;
68
68
  export const loadAudit = async () => (await import('../../commands/audit.js')).registerAuditCommands;
69
+ export const loadWebhook = async () => (await import('../../commands/webhook.js')).registerWebhookCommand;
70
+ export const loadFunnel = async () => (await import('../../commands/funnel.js')).registerFunnelCommand;
69
71
  /**
70
72
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
71
73
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -151,8 +153,15 @@ export const COMMAND_LOADERS = {
151
153
  events: [loadEvents],
152
154
  ssh: [loadSsh],
153
155
  devices: [loadSsh],
156
+ // `fleet` is a commander alias of `devices` (see commands/ssh.ts); list it so
157
+ // lazy registration loads the devices tree when the user types `agents fleet`.
158
+ fleet: [loadSsh],
154
159
  pull: [loadPull],
155
160
  push: [loadPush],
161
+ // `repos` is the canonical command name; `repo` remains a convenience alias
162
+ // (see commands/repo.ts). List both so lazy registration loads the tree
163
+ // whichever the user types.
164
+ repos: [loadRepo],
156
165
  repo: [loadRepo],
157
166
  setup: [loadSetup],
158
167
  sessions: [loadSessions],
@@ -162,4 +171,6 @@ export const COMMAND_LOADERS = {
162
171
  feed: [loadFeed],
163
172
  serve: [loadServe],
164
173
  audit: [loadAudit],
174
+ webhook: [loadWebhook],
175
+ funnel: [loadFunnel],
165
176
  };
@@ -109,13 +109,21 @@ export declare function getCacheDir(): string;
109
109
  export declare function getPackagesDir(): string;
110
110
  /** Path to routine YAML definitions (~/.agents/routines/). */
111
111
  export declare function getRoutinesDir(): string;
112
+ /**
113
+ * Path to built-in routine definitions shipped in the system repo
114
+ * (`~/.agents/.system/routines/`). Unioned under the user routines dir by
115
+ * listJobs()/readJob(): a routine shipped here fires for every install, and a
116
+ * user routine of the same name overrides it (a user copy with `enabled: false`
117
+ * disables the built-in). The daemon fires these; the directory need not exist.
118
+ */
119
+ export declare function getSystemRoutinesDir(): string;
112
120
  /**
113
121
  * Path to a project-scoped routines directory (`<project>/.agents/routines/`),
114
122
  * or null when no project `.agents/` is found by walking up from cwd.
115
123
  *
116
124
  * Project routines participate in `list`/`view`/`run` for inspection but are
117
- * NOT fired by the daemon (which runs from $HOME and only loads user routines).
118
- * Opt-in firing for project routines is tracked as a follow-up.
125
+ * NOT fired by the daemon (which runs from $HOME and loads only user + system
126
+ * routines). Opt-in firing for project routines is tracked as a follow-up.
119
127
  */
120
128
  export declare function getProjectRoutinesDir(cwd?: string): string | null;
121
129
  /** Path to routine execution logs (~/.agents/.history/runs/). */
package/dist/lib/state.js CHANGED
@@ -69,6 +69,10 @@ const SYSTEM_PERMISSIONS_DIR = path.join(SYSTEM_AGENTS_DIR, 'permissions');
69
69
  const SYSTEM_SUBAGENTS_DIR = path.join(SYSTEM_AGENTS_DIR, 'subagents');
70
70
  const SYSTEM_WORKFLOWS_DIR = path.join(SYSTEM_AGENTS_DIR, 'workflows');
71
71
  const SYSTEM_PLUGINS_DIR = path.join(SYSTEM_AGENTS_DIR, 'plugins');
72
+ // Built-in routines shipped in the system repo (gh:phnx-labs/.agents-system).
73
+ // Unioned under user routines by listJobs()/readJob() so a routine shipped here
74
+ // fires for every install, while a user routine of the same name overrides it.
75
+ const SYSTEM_ROUTINES_DIR = path.join(SYSTEM_AGENTS_DIR, 'routines');
72
76
  const SYSTEM_PROMPTCUTS_FILE = path.join(SYSTEM_AGENTS_DIR, 'hooks', 'promptcuts.yaml');
73
77
  const SYSTEM_MCP_CONFIG_FILE = path.join(SYSTEM_AGENTS_DIR, 'mcp.json');
74
78
  const SYSTEM_INSTRUCTIONS_FILE = path.join(SYSTEM_AGENTS_DIR, 'instructions.md');
@@ -296,13 +300,21 @@ export function getCacheDir() { return CACHE_DIR; }
296
300
  export function getPackagesDir() { return PACKAGES_DIR; }
297
301
  /** Path to routine YAML definitions (~/.agents/routines/). */
298
302
  export function getRoutinesDir() { return ROUTINES_DIR; }
303
+ /**
304
+ * Path to built-in routine definitions shipped in the system repo
305
+ * (`~/.agents/.system/routines/`). Unioned under the user routines dir by
306
+ * listJobs()/readJob(): a routine shipped here fires for every install, and a
307
+ * user routine of the same name overrides it (a user copy with `enabled: false`
308
+ * disables the built-in). The daemon fires these; the directory need not exist.
309
+ */
310
+ export function getSystemRoutinesDir() { return SYSTEM_ROUTINES_DIR; }
299
311
  /**
300
312
  * Path to a project-scoped routines directory (`<project>/.agents/routines/`),
301
313
  * or null when no project `.agents/` is found by walking up from cwd.
302
314
  *
303
315
  * Project routines participate in `list`/`view`/`run` for inspection but are
304
- * NOT fired by the daemon (which runs from $HOME and only loads user routines).
305
- * Opt-in firing for project routines is tracked as a follow-up.
316
+ * NOT fired by the daemon (which runs from $HOME and loads only user + system
317
+ * routines). Opt-in firing for project routines is tracked as a follow-up.
306
318
  */
307
319
  export function getProjectRoutinesDir(cwd = process.cwd()) {
308
320
  const projectAgentsDir = getProjectAgentsDir(cwd);