@phnx-labs/agents-cli 1.20.15 → 1.20.17
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 +9 -0
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions-sync.d.ts +13 -0
- package/dist/commands/sessions-sync.js +73 -0
- package/dist/commands/sessions.js +2 -0
- package/dist/commands/sync.d.ts +10 -3
- package/dist/commands/sync.js +72 -9
- package/dist/commands/view.js +11 -3
- package/dist/index.js +1 -1
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +11 -9
- package/dist/lib/daemon.d.ts +19 -0
- package/dist/lib/daemon.js +97 -2
- package/dist/lib/hooks.js +12 -0
- package/dist/lib/migrate.d.ts +22 -0
- package/dist/lib/migrate.js +99 -1
- package/dist/lib/plugin-marketplace.d.ts +15 -0
- package/dist/lib/plugin-marketplace.js +54 -0
- package/dist/lib/secrets/drivers/rush.d.ts +14 -0
- package/dist/lib/secrets/drivers/rush.js +84 -0
- package/dist/lib/secrets/index.js +20 -0
- package/dist/lib/secrets/linux.js +88 -10
- package/dist/lib/secrets/sync-backend.d.ts +48 -0
- package/dist/lib/secrets/sync-backend.js +13 -0
- package/dist/lib/secrets/sync.d.ts +15 -23
- package/dist/lib/secrets/sync.js +31 -66
- package/dist/lib/session/parse.d.ts +2 -0
- package/dist/lib/session/parse.js +168 -2
- package/dist/lib/session/sync/agents.d.ts +46 -0
- package/dist/lib/session/sync/agents.js +94 -0
- package/dist/lib/session/sync/config.d.ts +30 -0
- package/dist/lib/session/sync/config.js +58 -0
- package/dist/lib/session/sync/crdt.d.ts +44 -0
- package/dist/lib/session/sync/crdt.js +119 -0
- package/dist/lib/session/sync/manifest.d.ts +51 -0
- package/dist/lib/session/sync/manifest.js +96 -0
- package/dist/lib/session/sync/r2.d.ts +32 -0
- package/dist/lib/session/sync/r2.js +121 -0
- package/dist/lib/session/sync/sync.d.ts +82 -0
- package/dist/lib/session/sync/sync.js +251 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +17 -1
- package/dist/lib/sync-umbrella.d.ts +76 -0
- package/dist/lib/sync-umbrella.js +125 -0
- package/dist/lib/teams/parsers.js +159 -1
- package/dist/lib/usage.d.ts +18 -0
- package/dist/lib/usage.js +25 -0
- package/dist/lib/versions.js +30 -13
- package/package.json +2 -1
|
@@ -38,6 +38,7 @@ let isAvailable = false;
|
|
|
38
38
|
// ---------- file fallback state ----------
|
|
39
39
|
let useFileFallback = false;
|
|
40
40
|
let warnedFallback = false;
|
|
41
|
+
let warnedAutoPassphrase = false;
|
|
41
42
|
let fileDirOverride = null;
|
|
42
43
|
let cachedPassphrase = null;
|
|
43
44
|
function fileDir() {
|
|
@@ -87,7 +88,13 @@ function preflight() {
|
|
|
87
88
|
checkedAvailability = true;
|
|
88
89
|
}
|
|
89
90
|
if (!isAvailable) {
|
|
90
|
-
|
|
91
|
+
// No secret-tool. Route to the encrypted-file fallback whenever a passphrase
|
|
92
|
+
// source exists or can be auto-provisioned: an explicit
|
|
93
|
+
// AGENTS_SECRETS_PASSPHRASE, an already-provisioned machine-local passphrase,
|
|
94
|
+
// or a headless context (no TTY) where getPassphrase() auto-provisions one.
|
|
95
|
+
// Only an INTERACTIVE session with none of these gets the install hint —
|
|
96
|
+
// installing libsecret is the better fix when someone is at the keyboard.
|
|
97
|
+
if (process.env.AGENTS_SECRETS_PASSPHRASE || machinePassphraseExists() || !process.stdin.isTTY) {
|
|
91
98
|
activateFileFallback();
|
|
92
99
|
return 'file';
|
|
93
100
|
}
|
|
@@ -141,6 +148,67 @@ function readPassphraseFromTty() {
|
|
|
141
148
|
fs.closeSync(fd);
|
|
142
149
|
}
|
|
143
150
|
}
|
|
151
|
+
/** Path of the auto-provisioned machine-local passphrase. Lives alongside the
|
|
152
|
+
* encrypted items but is never itself an item (no `.enc` suffix, so it's
|
|
153
|
+
* excluded from list/has/get and from fileFallbackPreviouslyActivated). */
|
|
154
|
+
function passphraseFilePath() {
|
|
155
|
+
return path.join(fileDir(), '.passphrase');
|
|
156
|
+
}
|
|
157
|
+
/** True if a machine-local passphrase has already been provisioned. */
|
|
158
|
+
function machinePassphraseExists() {
|
|
159
|
+
try {
|
|
160
|
+
return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function readMachinePassphrase() {
|
|
167
|
+
try {
|
|
168
|
+
const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
|
|
169
|
+
return p.length > 0 ? p : null;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
177
|
+
* file store, so `agents secrets` works out of the box on a headless box where
|
|
178
|
+
* the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
|
|
179
|
+
*
|
|
180
|
+
* Security model: this is encryption-at-rest with the key held in a 0600 file —
|
|
181
|
+
* the same posture as an SSH private key, and identical to the common
|
|
182
|
+
* "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
|
|
183
|
+
* keyring (key in a daemon's locked memory) is stronger but is unavailable
|
|
184
|
+
* without a graphical/unlocked session. For an off-disk key, set
|
|
185
|
+
* AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
|
|
186
|
+
*/
|
|
187
|
+
function provisionMachinePassphrase() {
|
|
188
|
+
const existing = readMachinePassphrase();
|
|
189
|
+
if (existing)
|
|
190
|
+
return existing;
|
|
191
|
+
ensureFileDir();
|
|
192
|
+
const generated = randomBytes(32).toString('base64');
|
|
193
|
+
const fp = passphraseFilePath();
|
|
194
|
+
try {
|
|
195
|
+
// wx: fail if a concurrent process created it first (then we read theirs).
|
|
196
|
+
fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
const raced = readMachinePassphrase();
|
|
200
|
+
if (raced)
|
|
201
|
+
return raced;
|
|
202
|
+
throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
|
|
203
|
+
}
|
|
204
|
+
if (!warnedAutoPassphrase) {
|
|
205
|
+
warnedAutoPassphrase = true;
|
|
206
|
+
process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
|
|
207
|
+
`machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
|
|
208
|
+
`for a key held off disk.\n`);
|
|
209
|
+
}
|
|
210
|
+
return generated;
|
|
211
|
+
}
|
|
144
212
|
function getPassphrase() {
|
|
145
213
|
if (cachedPassphrase !== null)
|
|
146
214
|
return cachedPassphrase;
|
|
@@ -149,16 +217,25 @@ function getPassphrase() {
|
|
|
149
217
|
cachedPassphrase = env;
|
|
150
218
|
return env;
|
|
151
219
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
220
|
+
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
221
|
+
// file-store key — prefer it for both interactive and headless runs so they
|
|
222
|
+
// always agree (a TTY run won't re-prompt once the file exists).
|
|
223
|
+
const onDisk = readMachinePassphrase();
|
|
224
|
+
if (onDisk) {
|
|
225
|
+
cachedPassphrase = onDisk;
|
|
226
|
+
return onDisk;
|
|
227
|
+
}
|
|
228
|
+
// First run, no env, no provisioned key: prompt when interactive, otherwise
|
|
229
|
+
// (headless — the reported bug) auto-provision instead of hard-failing.
|
|
230
|
+
if (process.stdin.isTTY) {
|
|
231
|
+
const p = readPassphraseFromTty();
|
|
232
|
+
if (!p)
|
|
233
|
+
throw new Error('No passphrase entered.');
|
|
234
|
+
cachedPassphrase = p;
|
|
235
|
+
return p;
|
|
156
236
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
throw new Error('No passphrase entered.');
|
|
160
|
-
cachedPassphrase = p;
|
|
161
|
-
return p;
|
|
237
|
+
cachedPassphrase = provisionMachinePassphrase();
|
|
238
|
+
return cachedPassphrase;
|
|
162
239
|
}
|
|
163
240
|
function deriveKey(passphrase, salt) {
|
|
164
241
|
return scryptSync(passphrase, salt, 32);
|
|
@@ -423,6 +500,7 @@ export function _resetForTest(opts = {}) {
|
|
|
423
500
|
fileDirOverride = opts.fileDir ?? null;
|
|
424
501
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
425
502
|
warnedFallback = false;
|
|
503
|
+
warnedAutoPassphrase = false;
|
|
426
504
|
cachedPassphrase = opts.passphrase ?? null;
|
|
427
505
|
checkedAvailability = false;
|
|
428
506
|
isAvailable = false;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seam for encrypted secrets-bundle sync.
|
|
3
|
+
*
|
|
4
|
+
* A `SyncBackend` moves opaque ciphertext envelopes to and from some remote.
|
|
5
|
+
* It NEVER sees plaintext: encryption (`encryptBlob`) happens in `sync.ts`
|
|
6
|
+
* before `putEnvelope`, and decryption (`decryptBlob`) happens after
|
|
7
|
+
* `getEnvelope`. This mirrors the `KeychainBackend` storage seam
|
|
8
|
+
* (`src/lib/secrets/index.ts`) but abstracts *transport* rather than at-rest
|
|
9
|
+
* storage — so the high-level push/pull logic in `sync.ts` is decoupled from
|
|
10
|
+
* any specific backend (Rush's api.prix.dev, a future Supabase driver, an
|
|
11
|
+
* in-memory test double, …).
|
|
12
|
+
*/
|
|
13
|
+
/** Encrypted bundle envelope (AES-256-GCM, key via PBKDF2-SHA256). All byte
|
|
14
|
+
* fields are base64. The server only ever stores/returns this — never plaintext. */
|
|
15
|
+
export interface EncryptedEnvelope {
|
|
16
|
+
v: 1;
|
|
17
|
+
kdf: 'pbkdf2-sha256';
|
|
18
|
+
iter: number;
|
|
19
|
+
salt: string;
|
|
20
|
+
iv: string;
|
|
21
|
+
ct: string;
|
|
22
|
+
tag: string;
|
|
23
|
+
}
|
|
24
|
+
/** A stored bundle object: the ciphertext envelope plus a last-updated stamp. */
|
|
25
|
+
export interface SyncEnvelope {
|
|
26
|
+
envelope: EncryptedEnvelope;
|
|
27
|
+
updated_at: string;
|
|
28
|
+
}
|
|
29
|
+
/** Lightweight listing entry returned by `listEnvelopes`. */
|
|
30
|
+
export interface RemoteBundleSummary {
|
|
31
|
+
name: string;
|
|
32
|
+
updated_at: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pluggable transport for encrypted bundles. Implementations handle only the
|
|
36
|
+
* wire/storage; the crypto and bundle snapshot/restore stay backend-agnostic
|
|
37
|
+
* in `sync.ts`.
|
|
38
|
+
*/
|
|
39
|
+
export interface SyncBackend {
|
|
40
|
+
/** Store (create or overwrite) the envelope for `name`. */
|
|
41
|
+
putEnvelope(name: string, payload: SyncEnvelope): Promise<void>;
|
|
42
|
+
/** Fetch the envelope for `name`, or `null` if the remote has none. */
|
|
43
|
+
getEnvelope(name: string): Promise<SyncEnvelope | null>;
|
|
44
|
+
/** Delete `name` on the remote. Returns false if it didn't exist. */
|
|
45
|
+
deleteEnvelope(name: string): Promise<boolean>;
|
|
46
|
+
/** List every bundle the authenticated user has on the remote. */
|
|
47
|
+
listEnvelopes(): Promise<RemoteBundleSummary[]>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seam for encrypted secrets-bundle sync.
|
|
3
|
+
*
|
|
4
|
+
* A `SyncBackend` moves opaque ciphertext envelopes to and from some remote.
|
|
5
|
+
* It NEVER sees plaintext: encryption (`encryptBlob`) happens in `sync.ts`
|
|
6
|
+
* before `putEnvelope`, and decryption (`decryptBlob`) happens after
|
|
7
|
+
* `getEnvelope`. This mirrors the `KeychainBackend` storage seam
|
|
8
|
+
* (`src/lib/secrets/index.ts`) but abstracts *transport* rather than at-rest
|
|
9
|
+
* storage — so the high-level push/pull logic in `sync.ts` is decoupled from
|
|
10
|
+
* any specific backend (Rush's api.prix.dev, a future Supabase driver, an
|
|
11
|
+
* in-memory test double, …).
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -1,28 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote sync
|
|
2
|
+
* Remote sync for secrets bundles — backend-agnostic.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the previous "leave it to iCloud Keychain" model with explicit
|
|
5
|
-
* push/pull
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* push/pull. Bundle contents (vars + secret values) are encrypted client-side
|
|
6
|
+
* with AES-256-GCM under a key derived from a user-supplied passphrase via
|
|
7
|
+
* PBKDF2-SHA256; only the resulting ciphertext envelope is handed to the
|
|
8
|
+
* transport. The transport itself is a pluggable `SyncBackend` (see
|
|
9
|
+
* `sync-backend.ts`) — the Rush driver (`drivers/rush.ts`, api.prix.dev) is the
|
|
10
|
+
* default for backwards compatibility, swappable via `setSyncBackend`. Plaintext
|
|
11
|
+
* never leaves this module; the backend only ever sees ciphertext + KDF params.
|
|
9
12
|
*/
|
|
10
13
|
import { type SecretsBundle } from './bundles.js';
|
|
14
|
+
import type { SyncBackend, RemoteBundleSummary, EncryptedEnvelope } from './sync-backend.js';
|
|
15
|
+
export type { EncryptedEnvelope, RemoteBundleSummary } from './sync-backend.js';
|
|
11
16
|
export declare const MIN_PASSPHRASE_LEN = 12;
|
|
12
|
-
/**
|
|
13
|
-
export
|
|
14
|
-
v: 1;
|
|
15
|
-
kdf: 'pbkdf2-sha256';
|
|
16
|
-
iter: number;
|
|
17
|
-
salt: string;
|
|
18
|
-
iv: string;
|
|
19
|
-
ct: string;
|
|
20
|
-
tag: string;
|
|
21
|
-
}
|
|
22
|
-
interface RemoteBundleSummary {
|
|
23
|
-
name: string;
|
|
24
|
-
updated_at: string;
|
|
25
|
-
}
|
|
17
|
+
/** Override the sync transport. Returns the previous backend (restore in tests). */
|
|
18
|
+
export declare function setSyncBackend(next: SyncBackend): SyncBackend;
|
|
26
19
|
/** Encrypt a JSON-serializable payload with a passphrase. */
|
|
27
20
|
export declare function encryptBlob(plaintext: string, passphrase: string): EncryptedEnvelope;
|
|
28
21
|
/** Decrypt an envelope. Throws on bad passphrase (auth tag mismatch). */
|
|
@@ -37,7 +30,7 @@ export interface BundleSnapshot {
|
|
|
37
30
|
export interface PushOptions {
|
|
38
31
|
passphrase: string;
|
|
39
32
|
}
|
|
40
|
-
/** Push a local bundle to
|
|
33
|
+
/** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
|
|
41
34
|
export declare function pushBundle(name: string, opts: PushOptions): Promise<{
|
|
42
35
|
updated_at: string;
|
|
43
36
|
}>;
|
|
@@ -47,10 +40,9 @@ export interface PullOptions {
|
|
|
47
40
|
/** When true, overwrite an existing local bundle. */
|
|
48
41
|
force?: boolean;
|
|
49
42
|
}
|
|
50
|
-
/** Pull a bundle by name from
|
|
43
|
+
/** Pull a bundle by name from the remote and materialize it locally. */
|
|
51
44
|
export declare function pullBundle(name: string, opts: PullOptions): Promise<SecretsBundle>;
|
|
52
45
|
/** Delete a bundle on the remote. */
|
|
53
46
|
export declare function deleteRemoteBundle(name: string): Promise<boolean>;
|
|
54
|
-
/** List bundles currently stored on
|
|
47
|
+
/** List bundles currently stored on the remote for this user. */
|
|
55
48
|
export declare function listRemoteBundles(): Promise<RemoteBundleSummary[]>;
|
|
56
|
-
export {};
|
package/dist/lib/secrets/sync.js
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Remote sync
|
|
2
|
+
* Remote sync for secrets bundles — backend-agnostic.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the previous "leave it to iCloud Keychain" model with explicit
|
|
5
|
-
* push/pull
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* push/pull. Bundle contents (vars + secret values) are encrypted client-side
|
|
6
|
+
* with AES-256-GCM under a key derived from a user-supplied passphrase via
|
|
7
|
+
* PBKDF2-SHA256; only the resulting ciphertext envelope is handed to the
|
|
8
|
+
* transport. The transport itself is a pluggable `SyncBackend` (see
|
|
9
|
+
* `sync-backend.ts`) — the Rush driver (`drivers/rush.ts`, api.prix.dev) is the
|
|
10
|
+
* default for backwards compatibility, swappable via `setSyncBackend`. Plaintext
|
|
11
|
+
* never leaves this module; the backend only ever sees ciphertext + KDF params.
|
|
9
12
|
*/
|
|
10
13
|
import * as crypto from 'crypto';
|
|
11
|
-
import * as fs from 'fs';
|
|
12
|
-
import * as os from 'os';
|
|
13
|
-
import * as path from 'path';
|
|
14
|
-
import * as yaml from 'yaml';
|
|
15
14
|
import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from './index.js';
|
|
16
15
|
import { readBundle, writeBundle, keychainItemsForBundle, validateBundleName, } from './bundles.js';
|
|
17
|
-
|
|
18
|
-
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
19
|
-
const BUNDLE_ENDPOINT = '/api/v1/secrets/bundles';
|
|
16
|
+
import { rushSyncBackend } from './drivers/rush.js';
|
|
20
17
|
// PBKDF2 cost. 600k SHA-256 iters matches OWASP 2023+ guidance and keeps a
|
|
21
18
|
// passphrase prompt under a second on the hardware the CLI targets.
|
|
22
19
|
const PBKDF2_ITER = 600_000;
|
|
@@ -24,29 +21,19 @@ export const MIN_PASSPHRASE_LEN = 12;
|
|
|
24
21
|
const KEY_LEN = 32;
|
|
25
22
|
const SALT_LEN = 16;
|
|
26
23
|
const IV_LEN = 12;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const token = readRushToken();
|
|
41
|
-
const url = endpoint.startsWith('http') ? endpoint : `${PROXY_BASE}${endpoint}`;
|
|
42
|
-
return fetch(url, {
|
|
43
|
-
method,
|
|
44
|
-
headers: {
|
|
45
|
-
Authorization: `Bearer ${token}`,
|
|
46
|
-
'Content-Type': 'application/json',
|
|
47
|
-
},
|
|
48
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
49
|
-
});
|
|
24
|
+
/**
|
|
25
|
+
* Active transport backend. Defaults to the Rush driver for backwards
|
|
26
|
+
* compatibility with bundles already pushed to api.prix.dev; `setSyncBackend`
|
|
27
|
+
* swaps it (a future Supabase driver, or an in-memory double in tests). The
|
|
28
|
+
* crypto + snapshot/restore below stay backend-agnostic — the backend only
|
|
29
|
+
* ever moves ciphertext envelopes, never plaintext.
|
|
30
|
+
*/
|
|
31
|
+
let backend = rushSyncBackend;
|
|
32
|
+
/** Override the sync transport. Returns the previous backend (restore in tests). */
|
|
33
|
+
export function setSyncBackend(next) {
|
|
34
|
+
const prev = backend;
|
|
35
|
+
backend = next;
|
|
36
|
+
return prev;
|
|
50
37
|
}
|
|
51
38
|
function deriveKey(passphrase, salt) {
|
|
52
39
|
return crypto.pbkdf2Sync(passphrase, salt, PBKDF2_ITER, KEY_LEN, 'sha256');
|
|
@@ -115,32 +102,23 @@ function restoreSnapshot(snap) {
|
|
|
115
102
|
}
|
|
116
103
|
writeBundle(bundle);
|
|
117
104
|
}
|
|
118
|
-
/** Push a local bundle to
|
|
105
|
+
/** Push a local bundle to the remote. Encrypts client-side; the backend only sees ciphertext. */
|
|
119
106
|
export async function pushBundle(name, opts) {
|
|
120
107
|
validateBundleName(name);
|
|
121
108
|
const snap = snapshotBundle(name);
|
|
122
109
|
const envelope = encryptBlob(JSON.stringify(snap), opts.passphrase);
|
|
123
110
|
const updated_at = new Date().toISOString();
|
|
124
111
|
const payload = { envelope, updated_at };
|
|
125
|
-
|
|
126
|
-
if (!res.ok) {
|
|
127
|
-
const body = await res.text().catch(() => '');
|
|
128
|
-
throw new Error(`Push failed (${res.status} ${res.statusText}): ${body}`);
|
|
129
|
-
}
|
|
112
|
+
await backend.putEnvelope(name, payload);
|
|
130
113
|
return { updated_at };
|
|
131
114
|
}
|
|
132
|
-
/** Pull a bundle by name from
|
|
115
|
+
/** Pull a bundle by name from the remote and materialize it locally. */
|
|
133
116
|
export async function pullBundle(name, opts) {
|
|
134
117
|
validateBundleName(name);
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
throw new Error(`Remote bundle '${name}' not found
|
|
138
|
-
}
|
|
139
|
-
if (!res.ok) {
|
|
140
|
-
const body = await res.text().catch(() => '');
|
|
141
|
-
throw new Error(`Pull failed (${res.status} ${res.statusText}): ${body}`);
|
|
118
|
+
const data = await backend.getEnvelope(name);
|
|
119
|
+
if (!data) {
|
|
120
|
+
throw new Error(`Remote bundle '${name}' not found.`);
|
|
142
121
|
}
|
|
143
|
-
const data = await res.json();
|
|
144
122
|
const plaintext = decryptBlob(data.envelope, opts.passphrase);
|
|
145
123
|
const snap = JSON.parse(plaintext);
|
|
146
124
|
if (!snap || !snap.bundle || snap.bundle.name !== name) {
|
|
@@ -159,22 +137,9 @@ export async function pullBundle(name, opts) {
|
|
|
159
137
|
/** Delete a bundle on the remote. */
|
|
160
138
|
export async function deleteRemoteBundle(name) {
|
|
161
139
|
validateBundleName(name);
|
|
162
|
-
|
|
163
|
-
if (res.status === 404)
|
|
164
|
-
return false;
|
|
165
|
-
if (!res.ok) {
|
|
166
|
-
const body = await res.text().catch(() => '');
|
|
167
|
-
throw new Error(`Delete failed (${res.status} ${res.statusText}): ${body}`);
|
|
168
|
-
}
|
|
169
|
-
return true;
|
|
140
|
+
return backend.deleteEnvelope(name);
|
|
170
141
|
}
|
|
171
|
-
/** List bundles currently stored on
|
|
142
|
+
/** List bundles currently stored on the remote for this user. */
|
|
172
143
|
export async function listRemoteBundles() {
|
|
173
|
-
|
|
174
|
-
if (!res.ok) {
|
|
175
|
-
const body = await res.text().catch(() => '');
|
|
176
|
-
throw new Error(`List failed (${res.status} ${res.statusText}): ${body}`);
|
|
177
|
-
}
|
|
178
|
-
const data = await res.json();
|
|
179
|
-
return data.bundles ?? [];
|
|
144
|
+
return backend.listEnvelopes();
|
|
180
145
|
}
|
|
@@ -49,3 +49,5 @@ export declare function parseOpenCode(filePath: string): SessionEvent[];
|
|
|
49
49
|
export declare function parseRush(filePath: string): SessionEvent[];
|
|
50
50
|
/** Parse a Hermes session JSON file into normalized events. */
|
|
51
51
|
export declare function parseHermes(filePath: string): SessionEvent[];
|
|
52
|
+
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
53
|
+
export declare function parseKimi(filePath: string): SessionEvent[];
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* objects suitable for rendering, filtering, and summarization.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { execFileSync } from 'child_process';
|
|
10
11
|
/**
|
|
11
12
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
@@ -106,8 +107,8 @@ export function parseSession(filePath, agent) {
|
|
|
106
107
|
events = parseHermes(filePath);
|
|
107
108
|
break;
|
|
108
109
|
case 'kimi':
|
|
109
|
-
events =
|
|
110
|
-
break;
|
|
110
|
+
events = parseKimi(filePath);
|
|
111
|
+
break;
|
|
111
112
|
}
|
|
112
113
|
// Chokepoint: every string field that originated in an untrusted session
|
|
113
114
|
// file gets stripped of terminal escapes here, so renderers downstream can
|
|
@@ -130,6 +131,8 @@ export function detectAgent(filePath) {
|
|
|
130
131
|
return 'rush';
|
|
131
132
|
if (filePath.includes('/.hermes/') || filePath.includes('\\.hermes\\'))
|
|
132
133
|
return 'hermes';
|
|
134
|
+
if (filePath.includes('/.kimi-code/') || filePath.includes('\\.kimi-code\\'))
|
|
135
|
+
return 'kimi';
|
|
133
136
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
134
137
|
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
135
138
|
if (cloudMatch)
|
|
@@ -958,3 +961,166 @@ function hermesContentToText(content) {
|
|
|
958
961
|
.join('\n')
|
|
959
962
|
.trim();
|
|
960
963
|
}
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// Kimi parser
|
|
966
|
+
//
|
|
967
|
+
// Kimi stores session metadata in state.json and the conversation transcript
|
|
968
|
+
// in agents/main/wire.jsonl under ~/.kimi-code/sessions/<workdir>/session_<uuid>/.
|
|
969
|
+
// wire.jsonl uses a role-based schema:
|
|
970
|
+
// - "context.append_message" with role=user/assistant -> messages
|
|
971
|
+
// - "context.append_loop_event" with content.part type=text/think -> message/thinking
|
|
972
|
+
// - "context.append_loop_event" with event.type=tool.call -> tool_use
|
|
973
|
+
// - "context.append_loop_event" with event.type=tool.result -> tool_result
|
|
974
|
+
// - "usage.record" -> usage
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
/** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
|
|
977
|
+
export function parseKimi(filePath) {
|
|
978
|
+
const sessionDir = path.dirname(filePath);
|
|
979
|
+
const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
|
980
|
+
if (!fs.existsSync(wirePath)) {
|
|
981
|
+
return [];
|
|
982
|
+
}
|
|
983
|
+
const content = safeReadSessionFile(wirePath);
|
|
984
|
+
const lines = content.split('\n').filter(l => l.trim());
|
|
985
|
+
const events = [];
|
|
986
|
+
// Map tool.call uuid -> tool name so tool.result can carry the tool name.
|
|
987
|
+
const toolCallMap = new Map();
|
|
988
|
+
function extractMessageText(rawContent) {
|
|
989
|
+
if (typeof rawContent === 'string')
|
|
990
|
+
return rawContent.trim();
|
|
991
|
+
if (Array.isArray(rawContent)) {
|
|
992
|
+
return rawContent
|
|
993
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
994
|
+
.join('')
|
|
995
|
+
.trim();
|
|
996
|
+
}
|
|
997
|
+
return '';
|
|
998
|
+
}
|
|
999
|
+
function timestampFrom(raw) {
|
|
1000
|
+
const t = raw?.time;
|
|
1001
|
+
if (typeof t === 'number' && t > 0) {
|
|
1002
|
+
return new Date(t).toISOString();
|
|
1003
|
+
}
|
|
1004
|
+
return new Date().toISOString();
|
|
1005
|
+
}
|
|
1006
|
+
for (const line of lines) {
|
|
1007
|
+
let raw;
|
|
1008
|
+
try {
|
|
1009
|
+
raw = JSON.parse(line);
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const type = raw?.type;
|
|
1015
|
+
const timestamp = timestampFrom(raw);
|
|
1016
|
+
if (type === 'context.append_message') {
|
|
1017
|
+
const message = raw.message || {};
|
|
1018
|
+
const role = message.role === 'user' ? 'user' : 'assistant';
|
|
1019
|
+
const text = extractMessageText(message.content);
|
|
1020
|
+
if (!text)
|
|
1021
|
+
continue;
|
|
1022
|
+
events.push({
|
|
1023
|
+
type: 'message',
|
|
1024
|
+
agent: 'kimi',
|
|
1025
|
+
timestamp,
|
|
1026
|
+
role,
|
|
1027
|
+
content: text,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
else if (type === 'context.append_loop_event') {
|
|
1031
|
+
const event = raw.event || {};
|
|
1032
|
+
const eventType = event.type;
|
|
1033
|
+
if (eventType === 'content.part') {
|
|
1034
|
+
const part = event.part || {};
|
|
1035
|
+
const partType = part.type;
|
|
1036
|
+
if (partType === 'text') {
|
|
1037
|
+
const text = typeof part.text === 'string' ? part.text.trim() : '';
|
|
1038
|
+
if (text) {
|
|
1039
|
+
events.push({
|
|
1040
|
+
type: 'message',
|
|
1041
|
+
agent: 'kimi',
|
|
1042
|
+
timestamp,
|
|
1043
|
+
role: 'assistant',
|
|
1044
|
+
content: text,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
else if (partType === 'think') {
|
|
1049
|
+
const think = typeof part.think === 'string' ? part.think.trim() : '';
|
|
1050
|
+
if (think) {
|
|
1051
|
+
events.push({
|
|
1052
|
+
type: 'thinking',
|
|
1053
|
+
agent: 'kimi',
|
|
1054
|
+
timestamp,
|
|
1055
|
+
content: think,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
else if (eventType === 'tool.call') {
|
|
1061
|
+
const fn = event.function || {};
|
|
1062
|
+
const toolName = typeof event.name === 'string' ? event.name : (fn.name || 'unknown');
|
|
1063
|
+
let args = {};
|
|
1064
|
+
if (typeof fn.arguments === 'string') {
|
|
1065
|
+
try {
|
|
1066
|
+
args = JSON.parse(fn.arguments);
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
args = { _raw: fn.arguments };
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
else if (fn.arguments && typeof fn.arguments === 'object') {
|
|
1073
|
+
args = fn.arguments;
|
|
1074
|
+
}
|
|
1075
|
+
const callId = event.toolCallId || event.uuid;
|
|
1076
|
+
if (callId) {
|
|
1077
|
+
toolCallMap.set(callId, toolName);
|
|
1078
|
+
}
|
|
1079
|
+
events.push({
|
|
1080
|
+
type: 'tool_use',
|
|
1081
|
+
agent: 'kimi',
|
|
1082
|
+
timestamp,
|
|
1083
|
+
tool: toolName,
|
|
1084
|
+
args,
|
|
1085
|
+
path: args.path || args.file_path || undefined,
|
|
1086
|
+
command: toolName === 'Bash' ? args.command : undefined,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
else if (eventType === 'tool.result') {
|
|
1090
|
+
const callId = event.toolCallId || event.parentUuid;
|
|
1091
|
+
const toolName = (callId && toolCallMap.get(callId)) || 'unknown';
|
|
1092
|
+
const result = event.result || {};
|
|
1093
|
+
const output = typeof result.output === 'string' ? result.output : '';
|
|
1094
|
+
const isError = result.isError === true || (output && output.startsWith('Error:'));
|
|
1095
|
+
events.push({
|
|
1096
|
+
type: isError ? 'error' : 'tool_result',
|
|
1097
|
+
agent: 'kimi',
|
|
1098
|
+
timestamp,
|
|
1099
|
+
tool: toolName,
|
|
1100
|
+
success: !isError,
|
|
1101
|
+
output: output.length > 500 ? output.slice(0, 497) + '...' : output,
|
|
1102
|
+
});
|
|
1103
|
+
if (callId) {
|
|
1104
|
+
toolCallMap.delete(callId);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
else if (type === 'usage.record') {
|
|
1109
|
+
const usage = raw.usage || {};
|
|
1110
|
+
const inputTokens = usage.inputOther ?? usage.input_tokens;
|
|
1111
|
+
const outputTokens = usage.output ?? usage.output_tokens;
|
|
1112
|
+
if ((typeof inputTokens === 'number' && inputTokens >= 0) ||
|
|
1113
|
+
(typeof outputTokens === 'number' && outputTokens >= 0)) {
|
|
1114
|
+
events.push({
|
|
1115
|
+
type: 'usage',
|
|
1116
|
+
agent: 'kimi',
|
|
1117
|
+
timestamp,
|
|
1118
|
+
model: raw.model || usage.model,
|
|
1119
|
+
inputTokens: typeof inputTokens === 'number' ? inputTokens : undefined,
|
|
1120
|
+
outputTokens: typeof outputTokens === 'number' ? outputTokens : undefined,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return events;
|
|
1126
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent adapter for sync. Each supported agent declares where its
|
|
3
|
+
* transcripts live and how to derive a session id and storage-relative key
|
|
4
|
+
* from a file path. The merge (crdt.ts) and transport (r2.ts) are fully
|
|
5
|
+
* agent-agnostic — adding a new agent is just another entry in SYNC_AGENTS.
|
|
6
|
+
*
|
|
7
|
+
* Mirror layout: synced-in transcripts land under
|
|
8
|
+
* ~/.agents/.history/backups/<agent>/<machine>/<subdir>/<relKey>
|
|
9
|
+
* which is already a scan root (getAgentSessionDirs scans backups/<agent>/<ts>),
|
|
10
|
+
* so the existing incremental scanner indexes them with no changes. Because the
|
|
11
|
+
* scanner dedups by session id with the live home scanned first, a session that
|
|
12
|
+
* also exists locally always wins — the mirror only ever fills in sessions
|
|
13
|
+
* originated on other machines.
|
|
14
|
+
*/
|
|
15
|
+
export interface LocalTranscript {
|
|
16
|
+
/** Absolute path on this machine. */
|
|
17
|
+
absPath: string;
|
|
18
|
+
/** Globally-unique session id (the grouping key across machines). */
|
|
19
|
+
sessionId: string;
|
|
20
|
+
/** Path relative to the agent's subdir root — preserved in the mirror layout. */
|
|
21
|
+
relKey: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SyncAgentSpec {
|
|
24
|
+
id: string;
|
|
25
|
+
/** Config subdir under the agent home that holds transcripts. */
|
|
26
|
+
subdir: string;
|
|
27
|
+
/** Derive the session id from a storage-relative key. */
|
|
28
|
+
sessionIdFromRelKey(relKey: string): string;
|
|
29
|
+
}
|
|
30
|
+
export declare const SYNC_AGENTS: SyncAgentSpec[];
|
|
31
|
+
/**
|
|
32
|
+
* List this machine's own transcript files for an agent, EXCLUDING the sync
|
|
33
|
+
* mirror (we never re-upload another machine's files under our prefix). Dedups
|
|
34
|
+
* by session id so a session present in multiple version homes is uploaded once.
|
|
35
|
+
*/
|
|
36
|
+
export declare function listLocalTranscripts(spec: SyncAgentSpec): LocalTranscript[];
|
|
37
|
+
/** Session ids this machine holds locally (live home), used to skip mirror writes. */
|
|
38
|
+
export declare function localSessionIds(spec: SyncAgentSpec): Set<string>;
|
|
39
|
+
/** Absolute mirror path for a remote machine's transcript — lands in a scan root. */
|
|
40
|
+
export declare function mirrorPath(spec: SyncAgentSpec, machine: string, relKey: string): string;
|
|
41
|
+
/** R2 object key for a transcript: sessions/<machine>/<agent>/<sessionId>.jsonl */
|
|
42
|
+
export declare function objectKey(machine: string, agentId: string, sessionId: string): string;
|
|
43
|
+
/** R2 object key for a machine's manifest. */
|
|
44
|
+
export declare function manifestKey(machine: string): string;
|
|
45
|
+
/** Prefix under which all machine manifests live (for discovery). */
|
|
46
|
+
export declare const SESSIONS_PREFIX = "sessions/";
|