@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.
- package/CHANGELOG.md +45 -0
- package/README.md +10 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +41 -0
- 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 +58 -18
- 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/daemon.js +2 -0
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/routines.d.ts +29 -10
- package/dist/lib/routines.js +47 -15
- 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/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/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
|
+
}
|
|
@@ -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
|
};
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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);
|