@phnx-labs/agents-cli 1.22.70 → 1.22.71
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 +18 -0
- package/README.md +31 -1
- package/dist/bootstrap.js +4 -4
- package/dist/commands/repo.js +2 -2
- package/dist/commands/sessions-export.d.ts +5 -1
- package/dist/commands/sessions-export.js +100 -24
- package/dist/commands/sessions-import.d.ts +2 -1
- package/dist/commands/sessions-import.js +85 -21
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +140 -19
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/daemon/auth-sync-service.js +1 -1
- package/dist/lib/daemon/browser-task-reap-service.js +1 -1
- package/dist/lib/daemon/daemon.js +13 -3
- package/dist/lib/daemon/heartbeat-service.js +3 -3
- package/dist/lib/daemon/keychain-reap-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +18 -1
- package/dist/lib/daemon/runner.js +231 -78
- package/dist/lib/daemon/self-heal-service.js +13 -3
- package/dist/lib/daemon/self-update-service.d.ts +174 -0
- package/dist/lib/daemon/self-update-service.js +353 -0
- package/dist/lib/daemon/state-dir-check-service.js +3 -3
- package/dist/lib/daemon/usage-sync-service.js +1 -1
- package/dist/lib/daemon/watchdog-service.js +4 -4
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +12 -1
- package/dist/lib/device-config.js +63 -13
- package/dist/lib/exec-bounded.d.ts +52 -0
- package/dist/lib/exec-bounded.js +113 -0
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/fleet-shared-state.d.ts +12 -5
- package/dist/lib/fleet-shared-state.js +50 -20
- package/dist/lib/fs-atomic.d.ts +11 -0
- package/dist/lib/fs-atomic.js +60 -0
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/project-resources.d.ts +12 -0
- package/dist/lib/project-resources.js +129 -0
- package/dist/lib/routine-process-cleanup.d.ts +2 -2
- package/dist/lib/routine-process-cleanup.js +45 -34
- package/dist/lib/secrets/reaper.d.ts +2 -2
- package/dist/lib/secrets/reaper.js +13 -10
- package/dist/lib/secrets/reserved-sync.d.ts +1 -1
- package/dist/lib/secrets/reserved-sync.js +4 -4
- package/dist/lib/self-update.d.ts +21 -8
- package/dist/lib/self-update.js +54 -31
- package/dist/lib/session/sync/backend.d.ts +61 -0
- package/dist/lib/session/sync/backend.js +89 -0
- package/dist/lib/session/sync/managed-config.d.ts +29 -0
- package/dist/lib/session/sync/managed-config.js +23 -0
- package/dist/lib/session/sync/managed-key.d.ts +45 -0
- package/dist/lib/session/sync/managed-key.js +128 -0
- package/dist/lib/session/sync/net-client.d.ts +65 -0
- package/dist/lib/session/sync/net-client.js +117 -0
- package/dist/lib/session/sync/provision.d.ts +19 -0
- package/dist/lib/session/sync/provision.js +38 -0
- package/dist/lib/session/sync/r2.d.ts +5 -2
- package/dist/lib/session/sync/r2.js +5 -2
- package/dist/lib/session/sync/worker-template.d.ts +6 -0
- package/dist/lib/session/sync/worker-template.js +847 -0
- package/dist/lib/tmux/orphan-reap.js +6 -4
- package/dist/lib/tmux/session.js +4 -1
- package/dist/lib/traces/classify.d.ts +8 -1
- package/dist/lib/traces/insights.d.ts +13 -1
- package/dist/lib/traces/insights.js +78 -3
- package/dist/lib/traces/sync.js +8 -3
- package/dist/lib/traces/worker-template.js +9 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.71
|
|
4
|
+
|
|
5
|
+
- **The daemon now keeps itself current instead of running stale code for days (PHNX-3695).** The long-running daemon (`agents __daemon-run`) deliberately opted out of the interactive CLI's auto-update (`AGENTS_CLI_DISABLE_AUTO_UPDATE=1` forced on for `__daemon-run`), so new agents-cli code never reached a running daemon until a human ran `agents daemon restart` — R5 ("the installed CLI auto-updates") silently did not hold for the one process that runs unattended the longest. A new supervised `self-update` service checks npm roughly every 75 minutes, installs and byte-verifies a newer version with the same primitives `agents upgrade` already uses (never a bare `npm install -g`), best-effort pulls the `.system` companion repo and reconciles with `agents sync --local`, then exits so the OS supervisor (launchd `KeepAlive` / systemd `Restart=always`) relaunches it onto the new code — clients reconnect on their own, and the scheduler's atomic `(routine, scheduledFor)` claim dedupes any routine mid-fire across the restart. It fails closed on every step: an install or verify failure leaves the running daemon untouched and retries next tick, and it no-ops on a dev build or a shadowed install. A version-skewed browser-IPC client no longer just prints "run agents daemon restart" — `reconcileDaemonVersion` now asks the daemon to run this same fail-closed path on demand (`request-self-update`), sharing one in-flight attempt with the periodic tick so concurrent requests can't race two installs. That on-demand trigger is DECOUPLED from the install: because every version-skewed `agents browser <verb>` routes through it, the daemon kicks the self-update off in the background and answers "triggered" immediately rather than making the browser verb wait out the whole check→download→install→verify (tens of seconds, worst case ~15 min) — the daemon installs, verifies, and exits on its own, and the browser reconnects. Source: `cli/src/lib/daemon/self-update-service.ts`, `cli/src/lib/browser/ipc.ts`.
|
|
6
|
+
|
|
7
|
+
- **The daemon's event loop is no longer starved by synchronous subprocess spawns on its service ticks (PHNX-3695).** Every daemon background service runs on one Node event loop, so a synchronous `execFileSync`/`readFileSync` inside a service tick froze that loop for the whole call — and while frozen the supervisor's per-tick deadline timer could not fire and the browser IPC server could not answer, which is the "accept but never reply" wedge the trivial `version` probe (`cli/src/lib/browser/ipc.ts`) surfaces. A new async, deadline-bounded, process-group-killable exec helper (`cli/src/lib/exec-bounded.ts` `execFileBounded`) replaces the tick-reachable sync spawns. The heartbeat tick is now fully async: it reaps exited routine children through `reapExitedRunningJobs` (an async twin of the sync `monitorRunningJobs`, sharing one reconciliation core so the CLI `routines list/status` builders keep their synchronous, off-loop path) with an `execFileBounded`-bounded `ps` identity probe and an async `sshExecAsync` read of a `host:`-placed run's remote exit (a synchronous `ssh` there would freeze the loop on a host timeout), plus the async terminal-routine reaper; the self-heal and state-dir-check service ticks moved their `existsSync`/`readFileSync` to `fs/promises`. The worst halt was the event-log **file lock** (`withFileLock` → `lockfile.lockSync` + `Atomics.wait`, up to 30s), reached from every `ctx.log` on every tick as well as the watchdog and routine-reaper emits; a new `withFileLockAsync` + `emitAsync` (`cli/src/lib/fs-atomic.ts`, `cli/src/lib/feed/events.ts`) acquire it without freezing the loop. The other unconditional every-tick halts are converted the same way: usage-sync/auth-sync publish through `updateFleetSharedDeviceStateAsync`, keychain-reap's whole-process-table `ps` uses `execFileBounded`, watchdog/browser-task-reap read config via async paths, and tmux-reap's `/proc` scan uses `fs/promises`. A structural guard test (`cli/src/lib/daemon/guard-no-sync-io.test.ts`) scans every `*-service.ts` tick/start body for a synchronous fs/exec/lock call AND pins that each hot tick call site uses its async variant (failing if a fix is reverted to its sync twin). Startup and stop/uninstall lifecycle code (pid/lock, `launchctl`/`systemctl`, the `ps` daemon-identity probe) stays synchronous — it runs in a short-lived CLI process, never on the served loop. Source: `cli/src/lib/{exec-bounded,fs-atomic,device-config,fleet-shared-state}.ts`, `cli/src/lib/feed/events.ts`, `cli/src/lib/secrets/{reaper,reserved-sync}.ts`, `cli/src/lib/accounting/usage-sync.ts`, `cli/src/lib/tmux/{session,orphan-reap}.ts`, `cli/src/lib/daemon/{runner,daemon,heartbeat-service,watchdog-service,usage-sync-service,auth-sync-service,keychain-reap-service,browser-task-reap-service,self-heal-service,state-dir-check-service}.ts`, `cli/src/lib/hosts/reconcile.ts`, `cli/src/lib/routine-process-cleanup.ts`, `cli/src/lib/daemon/AGENTS.md`.
|
|
8
|
+
|
|
9
|
+
- **The required PR check no longer reinstalls 273 packages on every run.** Measured across recent Linux required runs, `bun install` took 14–22s on *every* run — whether the impact plan selected 78 test files or 2 — while a release check's actual test execution is 0.27s. The install was a fixed cost every pull request paid, and the largest single item in the required-check budget. `tests.yml` now restores `~/.bun/install/cache`, `cli/node_modules`, and `packages/session-tracker/node_modules` from an `actions/cache` keyed on **both** `cli/bun.lock` and `packages/session-tracker/bun.lock` — keying on the CLI lockfile alone would have left the session-tracker tree outside its own key. The key also carries a `node -v`/`bun -v` fingerprint, because `cli/package.json` trusts a native addon (`@homebridge/node-pty-prebuilt-multiarch`) whose prebuilt binary is Node-ABI-specific and nothing pins the runner's Node. `restore-keys` is deliberately omitted: a prefix fallback would let bun install on top of a *different* lockfile's modules, and `--frozen-lockfile` only guarantees the tree matches the lock from a clean or exact-match state — correctness beats a warmer cache when an attestation binds the tested tree to the published tarball. A dependency change misses the cache and reinstalls, which is exactly the previous behavior, so a miss can never be slower than not caching. Source: `.github/workflows/tests.yml`.
|
|
10
|
+
|
|
11
|
+
- **The CI dependency cache now actually hits.** The cache added a change earlier was dead weight in practice: GitHub scopes caches per ref — a run can restore only from its own branch or the default branch — and the required `test` job runs on `pull_request` only, so every entry it saved landed under `refs/pull/<N>/merge`, invisible to every other pull request. Measured after it shipped: `deps-cache-hit=0` with `packages installed [21.63s]` on unrelated PRs, i.e. a permanent cold miss for everyone except a re-run of the same PR. A push-to-`main` `warm-dep-cache` job now writes the identical key and paths, so the first PR after any lockfile or toolchain change pays the install once and every later PR restores it. The job is push-only and never joins the required check identity. `tests-gate.test.ts` pins the two keys equal, because drift between them would be silent — the warm job would populate an entry the required job never asks for, and the cache would look healthy while never hitting. Source: `.github/workflows/tests.yml`.
|
|
12
|
+
|
|
13
|
+
- **Managed session backup: harden the storage boundary (PHNX-3726).** Follow-up security fixes to the managed sessions Worker landed in the same ticket. The Worker is now **Phoenix-only** — the static `WRITE_TOKEN` principal is removed (and no longer provisioned): the zero-knowledge `--byo` path talks to your own R2 bucket directly and never reaches this Worker, so a token here was only a Phoenix-and-quota bypass. Mandatory encryption is now enforced at the storage boundary, not just client-side: a PUT whose body is not an encrypted bundle is rejected `422`, and a managed restore fails loud on any plaintext object instead of importing it. DELETE now refunds the per-user quota ledger via a delta CAS applied BEFORE the object is removed, and fails loud (503, object left intact and charged) when that CAS stays contended — instead of deleting first and silently dropping the refund on a contended ledger. Source: `cli/src/lib/session/sync/{worker-template,provision}.ts`, `cli/src/commands/sessions-import.ts`, `cli/docs/specifications.md`.
|
|
14
|
+
|
|
15
|
+
- **Signed-in users back sessions up off-box with NO `r2.backups` bucket to set up (PHNX-3726).** `agents sessions export --to-r2` / `import --from-r2` now default to a managed, Phoenix-gated store (`sessions.agents-cli.sh`) when you are signed in (`agents auth login`) — the same zero-setup pattern `agents artifacts share` and `agents traces sync` already use, resolved through the one shared `selectStorageBackendKind` policy. It is **managed-first**: a signed-in user backs up to managed even with a stale `r2.backups` bundle present; only `--byo` / `AGENTS_SESSIONS_BACKEND=byo` flips to your own bucket. Every managed backup is **mandatorily** AES-256-GCM encrypted under a per-account key that is minted once, cached at `~/.agents/.cache/state/sessions-backup-key.json` (mode 0600), and escrowed at the bearer-gated, immutable Worker key `<userId>/__key/backup-dek` so a fresh box signing in recovers it and decrypts prior backups with zero setup — the managed path never uploads plaintext. Both the command and Worker reject plaintext/malformed managed objects, and restore revalidates every at-rest bundle before decryption. Honest trust boundary: confidential vs a raw R2/Cloudflare bucket read (ciphertext at rest) but NOT zero-knowledge vs Phoenix (the DEK is escrowed on Phoenix infra); `--byo` keeps the zero-knowledge own-bucket path (the key stays only in your `r2.backups` bundle). The isolated Worker verifies the Phoenix bearer on every PUT/GET/LIST/DELETE, enforces `segments[0] == userId` (401/403, no public GET), and meters a per-user CAS quota (`__usage`, 413 over quota). Same-key managed PUT/DELETE operations share an expiring R2 lease; every object change is etag-conditioned, DELETE leaves a GET-404/LIST-hidden tombstone, and expired recovery fences the predecessor's etag before settling quota. The ledger retains an idempotent pending delta plus a terminal generation per path in the same CAS object, preventing late predecessor charges, replacement deletes, double refunds, stranded paths, and phantom quota. A CAS-reserved historical-path budget is consumed before persistent per-path state is created and is not refunded on DELETE, so authenticated create/delete churn cannot grow tombstones, leases, or ledger history past the hard cap. Reserved `__usage`/`__key` prefixes are never listed. The BYO object layout is byte-for-byte unchanged (the `<userId>/` prefix is added inside the client) and the Worker is integration-tested against real workerd. Source: `cli/src/lib/session/sync/{backend,managed-config,managed-key,net-client,provision,worker-template}.ts`, `cli/src/commands/sessions-export.ts`, `cli/src/commands/sessions-import.ts`, `cli/docs/{sessions,specifications}.md`.
|
|
16
|
+
|
|
17
|
+
- **Daemon: a host-placed run finishing on the heartbeat tick no longer freezes the event loop (PHNX-3727).** Fast-follow to PHNX-3695. `finalizeHostRunAsync` (the async, tick-reachable finalize path reached via `reapExitedRunningJobs`) called `applyHealedHostRun`, which ended by emitting routine-end through the **synchronous** `emitRoutineEnd` — whose event-log file lock (`withFileLock` → `lockSync` + `Atomics.wait`, up to 30s under contention) is exactly the loop-freeze PHNX-3695 removed elsewhere, on a path `guard-no-sync-io` couldn't see (it lives in `runner.ts`, not a `*-service.ts`). So a `host:`-placed routine run completing could still wedge the browser-IPC "accept but never reply" state under lock contention. `applyHealedHostRun` now takes an injected emitter: `finalizeHostRun` (sync CLI path) keeps the synchronous emit, while `finalizeHostRunAsync` (the tick) passes `emitRoutineEndAsync` (`withFileLockAsync`), so no daemon tick reaches the synchronous lock. `guard-no-sync-io` gains a call-site pin so reverting it fails loud. Source: `cli/src/lib/daemon/runner.ts`, `cli/src/lib/daemon/guard-no-sync-io.test.ts`.
|
|
18
|
+
|
|
19
|
+
- **`agents traces sync` now surfaces silent behavioral failures as ranked issues, not just a friction counter (RUSH-2988).** A session where the agent went idle after its last event and a human had to nudge it is a real failure with **no error code**, so it never clustered through `computeInsights` (which only groups `outcome === 'error'` tool calls) and was previously only a `needsAttention` severity input. The per-session silent-stall friction (`silent stall: 5-15m/15-60m/1h+`, already computed by `computeInsightFacets`) is now promoted into cross-session `FailurePattern`s with a new `behavioral` failure cause (`cli/src/lib/traces/insights.ts` `computeBehavioralPatterns`), ranked into the same top-K `failurePatterns` by wasted idle time (a bucket's midpoint, bounded by the same 30-min `MAX_GAP_ATTRIBUTION_MS` cap the tool-error path uses) and summed into `wastedMsTotal`. `TraceFailureCause` gains `behavioral` (`cli/src/lib/traces/classify.ts`); it is producer-derived and never returned by `classifyCause`, so the `failures.byCause` tool-error split reports it as `0`. The console renders the new cause in a follow-up web change.
|
|
20
|
+
|
|
3
21
|
## 1.22.70
|
|
4
22
|
|
|
5
23
|
- **`agents browser stop --profile` is now gated by the remote-control consent gate (PHNX-3317).** `stop --profile <name>` (no `--task`) is handled entirely by `bindTask`'s early return in `ipc.ts`, so it never reached `resolveOrCreateTask` — the one chokepoint every other page/close/attach verb's `assertRemoteControlAllowedForRequest` gate runs through — and `case 'stop'` called `BrowserService.stopProfile()` directly with no consent check at all. That left one destructive fleet-remote path (kills the profile's browser process, clears its runtime dir) reachable from `browser --device <device> stop --profile` even with `remote-control off` on the target. `stopProfile()` now takes the same `{ fleetRemote, actor }` opts as `start`/`showUrl`/`resolveOrCreateTask` and asserts the gate first; a local (non-fleet-remote) call is unaffected. Source: `cli/src/lib/browser/service.ts`, `cli/src/lib/browser/ipc.ts`.
|
package/README.md
CHANGED
|
@@ -402,6 +402,31 @@ Sharing a session uses `agents sessions render <id> -o session.md`, not the raw
|
|
|
402
402
|
|
|
403
403
|
`agents sessions share <id>` goes one step further and publishes that document as a self-contained web page on your own share endpoint, printing the link. It is **unlisted** unless you pass `--public` — a transcript carries file paths, command output, and error text that a plan does not, so it stays out of your public gallery by default, and emails are masked on top of the render's own redaction. The slug is `session-<shortId>`, so re-sharing one session updates one URL.
|
|
404
404
|
|
|
405
|
+
### Back up sessions off-box
|
|
406
|
+
|
|
407
|
+
Signed-in Phoenix users can back sessions up without creating a Cloudflare bucket or
|
|
408
|
+
an `r2.backups` secrets bundle:
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
agents sessions export --since 30d --to-r2
|
|
412
|
+
agents sessions import --from-r2 --dry-run
|
|
413
|
+
agents sessions import --from-r2
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The managed path encrypts every transcript body locally with AES-256-GCM before
|
|
417
|
+
upload. Its per-account key is escrowed behind the same Phoenix bearer so another
|
|
418
|
+
signed-in device can restore with zero setup. This protects transcript contents from
|
|
419
|
+
a raw storage-bucket read, but it is not zero-knowledge against Phoenix because
|
|
420
|
+
Phoenix-operated infrastructure stores the recovery key. Use `--byo` with your own
|
|
421
|
+
`r2.backups` bundle and `R2_SYNC_ENC_KEY` when Phoenix must never possess the key:
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
agents sessions export --since 30d --to-r2 --byo
|
|
425
|
+
agents sessions import --from-r2 --byo
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Both targets are on-demand backups only; neither enables background session sync.
|
|
429
|
+
|
|
405
430
|
### Resume anywhere — and stay resumed
|
|
406
431
|
|
|
407
432
|
Pick up any past conversation and drop it back into a terminal:
|
|
@@ -1261,8 +1286,13 @@ agents daemon doctor # one-shot health check; non-zero ex
|
|
|
1261
1286
|
```
|
|
1262
1287
|
|
|
1263
1288
|
Each hosted responsibility (secrets broker, browser IPC, scheduler, monitors,
|
|
1264
|
-
watchdog, device probe, self-heal, keychain reap, account-state refresh,
|
|
1289
|
+
watchdog, device probe, self-heal, self-update, keychain reap, account-state refresh,
|
|
1265
1290
|
state-dir checks) is an independent toggle in `~/.agents/daemon/services.yaml`.
|
|
1291
|
+
Self-update checks npm on its own schedule, installs + verifies a newer
|
|
1292
|
+
agents-cli with the same primitives `agents upgrade` uses, then exits so the OS
|
|
1293
|
+
supervisor relaunches the daemon onto the new code — the daemon used to run
|
|
1294
|
+
with auto-update forced off and only picked up new code on a manual
|
|
1295
|
+
`agents daemon restart`.
|
|
1266
1296
|
`agents daemon services list` shows every service; `enable|disable <id>` flips
|
|
1267
1297
|
one. Missing keys default to enabled, so upgrades are no-ops. Most services take
|
|
1268
1298
|
effect on the next daemon start; browser IPC is registered even when boot-disabled
|
package/dist/bootstrap.js
CHANGED
|
@@ -328,7 +328,7 @@ async function installResolvedPackage(metadata) {
|
|
|
328
328
|
// every subsequent upgrade dead-ends there forever. bun does not use
|
|
329
329
|
// npm's retire-path staging scheme, so this only needs to run once, ahead
|
|
330
330
|
// of both package-manager branches below.
|
|
331
|
-
sweepStaleInstallStaging(packageRoot);
|
|
331
|
+
await sweepStaleInstallStaging(packageRoot);
|
|
332
332
|
// Upgrade with the package manager that owns this install. A bun global
|
|
333
333
|
// install lives at <bunGlobalDir>/node_modules/... (no `lib` segment), so an
|
|
334
334
|
// `npm install --prefix` would write to <bunGlobalDir>/lib/node_modules and
|
|
@@ -349,8 +349,8 @@ async function installResolvedPackage(metadata) {
|
|
|
349
349
|
/* leave it for the OS temp sweep */
|
|
350
350
|
}
|
|
351
351
|
}
|
|
352
|
-
verifyInstalledVersion(packageRoot, metadata.version);
|
|
353
|
-
refreshAliasShims(packageRoot);
|
|
352
|
+
await verifyInstalledVersion(packageRoot, metadata.version);
|
|
353
|
+
await refreshAliasShims(packageRoot);
|
|
354
354
|
// PHNX-2768: the npm install above can leave the package at the new version
|
|
355
355
|
// but the global bin links GONE — the state that stranded zion (package at
|
|
356
356
|
// 1.22.40, `/opt/homebrew/bin/{agents,ag,browser,computer}` missing, every
|
|
@@ -361,7 +361,7 @@ async function installResolvedPackage(metadata) {
|
|
|
361
361
|
// own bin shims and are out of scope.
|
|
362
362
|
if (detectPackageManager(packageRoot) !== 'bun' && process.platform !== 'win32') {
|
|
363
363
|
const prefix = deriveGlobalPrefix(packageRoot);
|
|
364
|
-
const repairs = ensureGlobalBinLinks(packageRoot, prefix);
|
|
364
|
+
const repairs = await ensureGlobalBinLinks(packageRoot, prefix);
|
|
365
365
|
const repaired = repairs.filter((r) => r.action === 'repaired');
|
|
366
366
|
const failed = repairs.filter((r) => r.action === 'failed');
|
|
367
367
|
if (repaired.length > 0) {
|
package/dist/commands/repo.js
CHANGED
|
@@ -60,7 +60,7 @@ function syncMarketplacesForDefaults() {
|
|
|
60
60
|
/** Write this device's account metadata before the user repo is committed. */
|
|
61
61
|
async function publishUserRepoAccountState(provisionAuth) {
|
|
62
62
|
const { publishUsageSnapshotToSharedStore } = await import('../lib/accounting/usage-sync.js');
|
|
63
|
-
const usage = publishUsageSnapshotToSharedStore();
|
|
63
|
+
const usage = await publishUsageSnapshotToSharedStore();
|
|
64
64
|
if (usage.error)
|
|
65
65
|
console.error(chalk.yellow(`Usage snapshot: ${usage.error}`));
|
|
66
66
|
if (provisionAuth) {
|
|
@@ -73,7 +73,7 @@ async function publishUserRepoAccountState(provisionAuth) {
|
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
75
|
const { publishReservedAuthVerdict } = await import('../lib/secrets/reserved-sync.js');
|
|
76
|
-
const auth = publishReservedAuthVerdict();
|
|
76
|
+
const auth = await publishReservedAuthVerdict();
|
|
77
77
|
if (auth.error)
|
|
78
78
|
console.error(chalk.yellow(`Auth verdict: ${auth.device}: ${auth.error}`));
|
|
79
79
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import type { SessionMeta } from '../lib/session/types.js';
|
|
3
|
+
import { type SessionsBackupClient } from '../lib/session/sync/net-client.js';
|
|
3
4
|
import { type BundleHeader, type BundleRecord } from '../lib/session/bundle.js';
|
|
4
5
|
export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
|
|
5
6
|
interface GlobalSelection {
|
|
@@ -10,6 +11,7 @@ interface GlobalSelection {
|
|
|
10
11
|
redact?: boolean;
|
|
11
12
|
encrypt?: boolean;
|
|
12
13
|
toR2?: boolean;
|
|
14
|
+
byo?: boolean;
|
|
13
15
|
output?: string;
|
|
14
16
|
stdout?: boolean;
|
|
15
17
|
host?: string[];
|
|
@@ -36,7 +38,9 @@ interface GlobalSelection {
|
|
|
36
38
|
* its test drive the exact same decision.
|
|
37
39
|
*/
|
|
38
40
|
export declare function r2ExportGateError(g: Pick<GlobalSelection, 'toR2' | 'host'>, isConfigured: boolean): string | null;
|
|
39
|
-
export declare function uploadToR2(header: BundleHeader, records: BundleRecord[]): Promise<void>;
|
|
41
|
+
export declare function uploadToR2(header: BundleHeader, records: BundleRecord[], resolvedClient?: SessionsBackupClient): Promise<void>;
|
|
42
|
+
/** Fail-closed guard at the managed transport boundary, not only at record construction. */
|
|
43
|
+
export declare function managedUploadEncryptionError(header: BundleHeader, records: BundleRecord[]): string | null;
|
|
40
44
|
/** R2 object key for one record — dir-shaped agents key by relKey, file-shaped by session. */
|
|
41
45
|
export declare function r2KeyForRecord(rec: BundleRecord): string;
|
|
42
46
|
/**
|
|
@@ -27,8 +27,11 @@ import { listLocalTranscripts, objectKey, SYNC_AGENTS } from '../lib/session/syn
|
|
|
27
27
|
import { machineId } from '../lib/machine-id.js';
|
|
28
28
|
import { getHistoryDir } from '../lib/state.js';
|
|
29
29
|
import { isSyncConfigured, loadR2Config } from '../lib/session/sync/config.js';
|
|
30
|
-
import { resolveSyncEncKey, generateSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
|
|
30
|
+
import { resolveSyncEncKey, generateSyncEncKey, isTranscriptEnvelope, } from '../lib/session/sync/transcript-crypto.js';
|
|
31
31
|
import { R2Client } from '../lib/session/sync/r2.js';
|
|
32
|
+
import { resolveSessionsBackend } from '../lib/session/sync/backend.js';
|
|
33
|
+
import { SessionsHttpClient } from '../lib/session/sync/net-client.js';
|
|
34
|
+
import { resolveManagedBackupKey } from '../lib/session/sync/managed-key.js';
|
|
32
35
|
import { buildRecord, makeHeader, mergeRecords, serializeBundle, writeBundleFile, specForAgent, } from '../lib/session/bundle.js';
|
|
33
36
|
import { knownSecretValuesFromEnv } from '../lib/redact.js';
|
|
34
37
|
import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
|
|
@@ -42,7 +45,8 @@ export function registerSessionsExportCommand(sessionsCmd) {
|
|
|
42
45
|
.option('-o, --output <path>', 'Write the bundle to this file')
|
|
43
46
|
.option('--stdout', 'Write the bundle to stdout (for piping into `sessions import -`)')
|
|
44
47
|
.option('--encrypt', 'Seal each transcript body with AES-256-GCM before writing')
|
|
45
|
-
.option('--to-r2', 'Back the selected sessions up
|
|
48
|
+
.option('--to-r2', 'Back the selected sessions up off-box (managed Phoenix store when signed in; your own r2.backups bucket with --byo) instead of a local file')
|
|
49
|
+
.option('--byo', 'With --to-r2: force your own r2.backups bucket instead of the managed Phoenix store');
|
|
46
50
|
setHelpSections(cmd, {
|
|
47
51
|
examples: `# Bundle the last week of sessions to a file
|
|
48
52
|
agents sessions export --since 7d -o week.bundle
|
|
@@ -53,17 +57,22 @@ agents sessions export 4f8a2b1c 9d3e7a55 -o pair.bundle
|
|
|
53
57
|
# Encrypt + pipe straight into another machine over SSH
|
|
54
58
|
agents sessions export --since 7d --stdout --encrypt | agents ssh boxB 'agents sessions import - --decrypt <key>'
|
|
55
59
|
|
|
56
|
-
# Back the last month up off-box
|
|
57
|
-
agents sessions export --since 30d --to-r2
|
|
60
|
+
# Back the last month up off-box (managed Phoenix store — no bucket to set up)
|
|
61
|
+
agents sessions export --since 30d --to-r2
|
|
62
|
+
|
|
63
|
+
# Or to your own r2.backups bucket (R2_SYNC_ENC_KEY keeps it zero-knowledge)
|
|
64
|
+
agents sessions export --since 30d --to-r2 --byo`,
|
|
58
65
|
notes: `Selection uses the same flags as 'agents sessions' (--since, -n/--limit, --all,
|
|
59
66
|
-a/--agent, --no-redact). Bundles are self-describing NDJSON: a header line + one
|
|
60
67
|
line per transcript file. Secrets are redacted by default. Dir-shaped sessions
|
|
61
68
|
(Kimi) carry all their files. Restore with 'agents sessions import'.
|
|
62
69
|
|
|
63
|
-
--to-r2
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
--to-r2 backs each session up off-box, one encrypted object per transcript keyed
|
|
71
|
+
by machine/agent/session. When you are signed in ('agents auth login') it uploads
|
|
72
|
+
to the MANAGED Phoenix store — no Cloudflare or r2.backups bucket to set up — and
|
|
73
|
+
every body is sealed with a per-account key (mandatory; never plaintext). --byo
|
|
74
|
+
forces your own r2.backups bucket instead; with R2_SYNC_ENC_KEY, neither Phoenix
|
|
75
|
+
nor the storage provider can decrypt it. Restore with 'agents sessions import --from-r2'.`,
|
|
67
76
|
});
|
|
68
77
|
cmd.action(async (selectors, _options, command) => {
|
|
69
78
|
await runExport(selectors, command);
|
|
@@ -71,13 +80,42 @@ same bundle with 'agents sessions import --from-r2'.`,
|
|
|
71
80
|
}
|
|
72
81
|
async function runExport(selectors, command) {
|
|
73
82
|
const g = command.optsWithGlobals();
|
|
74
|
-
// --to-r2 preflight:
|
|
75
|
-
//
|
|
76
|
-
//
|
|
83
|
+
// --to-r2 preflight. MANAGED-FIRST: a signed-in user backs up to the managed
|
|
84
|
+
// Phoenix store with no bucket to set up; --byo (or an unauthenticated user
|
|
85
|
+
// with an r2.backups bundle) uses their own bucket. isSyncConfigured() is
|
|
86
|
+
// consulted ONLY on the BYO path (it reads the keychain); the managed path
|
|
87
|
+
// authenticates with the Phoenix session and skips it entirely.
|
|
88
|
+
let sessionsClient;
|
|
89
|
+
let managedClient;
|
|
90
|
+
let managedBackupUserId;
|
|
91
|
+
if (g.byo && !g.toR2) {
|
|
92
|
+
process.stderr.write(chalk.red('--byo is only valid with --to-r2.\n'));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
77
95
|
if (g.toR2) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
process.
|
|
96
|
+
if (g.host && g.host.length > 0) {
|
|
97
|
+
process.stderr.write(chalk.red("--to-r2 backs up THIS machine's sessions; it cannot be combined with --device.\n"));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const backend = resolveSessionsBackend({ byo: g.byo });
|
|
102
|
+
if (backend.kind === 'managed') {
|
|
103
|
+
managedClient = new SessionsHttpClient({ baseUrl: backend.baseUrl, userId: backend.userId, token: backend.token });
|
|
104
|
+
sessionsClient = managedClient;
|
|
105
|
+
managedBackupUserId = backend.userId;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Preserve the explicit BYO gate used by the on-demand backup path and
|
|
109
|
+
// daemon sync cycle. resolveSessionsBackend already loaded the bundle,
|
|
110
|
+
// so this is a cached, non-prompting verification.
|
|
111
|
+
const gateErr = r2ExportGateError(g, isSyncConfigured());
|
|
112
|
+
if (gateErr)
|
|
113
|
+
throw new Error(gateErr);
|
|
114
|
+
sessionsClient = new R2Client(backend.r2);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
process.stderr.write(chalk.red(`Session backup: ${err.message}\n`));
|
|
81
119
|
process.exit(1);
|
|
82
120
|
}
|
|
83
121
|
}
|
|
@@ -142,9 +180,23 @@ async function runExport(selectors, command) {
|
|
|
142
180
|
process.exit(1);
|
|
143
181
|
}
|
|
144
182
|
// 4. Resolve encryption key (opt-in) + redaction (default on via parent --no-redact).
|
|
145
|
-
//
|
|
146
|
-
// unrecoverable on a fresh box)
|
|
147
|
-
|
|
183
|
+
// A BYO R2 backup uses the fleet-shared R2_SYNC_ENC_KEY (an ephemeral key would
|
|
184
|
+
// be unrecoverable on a fresh box). A MANAGED backup resolves a per-account DEK
|
|
185
|
+
// that is MANDATORY and never null — the managed path never uploads plaintext —
|
|
186
|
+
// minting + escrowing one on first use so a fresh box recovers it with zero setup.
|
|
187
|
+
let encryptKey;
|
|
188
|
+
if (g.toR2 && managedBackupUserId) {
|
|
189
|
+
encryptKey = await resolveManagedBackupKey(managedClient, managedBackupUserId);
|
|
190
|
+
}
|
|
191
|
+
else if (g.toR2) {
|
|
192
|
+
encryptKey = resolveR2BackupKey();
|
|
193
|
+
}
|
|
194
|
+
else if (g.encrypt) {
|
|
195
|
+
encryptKey = resolveExportKey();
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
encryptKey = null;
|
|
199
|
+
}
|
|
148
200
|
const redact = g.redact !== false;
|
|
149
201
|
// Value-aware redaction: mask live credential values already in the
|
|
150
202
|
// environment (e.g. an injected secrets bundle) verbatim, whatever their
|
|
@@ -172,7 +224,7 @@ async function runExport(selectors, command) {
|
|
|
172
224
|
records,
|
|
173
225
|
});
|
|
174
226
|
if (g.toR2) {
|
|
175
|
-
await uploadToR2(header, records);
|
|
227
|
+
await uploadToR2(header, records, sessionsClient);
|
|
176
228
|
return;
|
|
177
229
|
}
|
|
178
230
|
emitBundle(header, records, g);
|
|
@@ -205,13 +257,28 @@ export function r2ExportGateError(g, isConfigured) {
|
|
|
205
257
|
}
|
|
206
258
|
return null;
|
|
207
259
|
}
|
|
208
|
-
export async function uploadToR2(header, records) {
|
|
260
|
+
export async function uploadToR2(header, records, resolvedClient) {
|
|
261
|
+
// The resolved client (managed HTTP or BYO R2) is passed in by the command.
|
|
262
|
+
// The no-client overload preserves the original BYO-only signature for the
|
|
263
|
+
// direct unit test (loadR2Config() from the r2.backups bundle).
|
|
209
264
|
let client;
|
|
210
|
-
|
|
211
|
-
client =
|
|
265
|
+
if (resolvedClient) {
|
|
266
|
+
client = resolvedClient;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
try {
|
|
270
|
+
client = new R2Client(loadR2Config());
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
process.stderr.write(chalk.red(`R2 backup: ${err.message}\n`));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
212
276
|
}
|
|
213
|
-
|
|
214
|
-
|
|
277
|
+
const encryptionError = client.kind === 'managed'
|
|
278
|
+
? managedUploadEncryptionError(header, records)
|
|
279
|
+
: null;
|
|
280
|
+
if (encryptionError) {
|
|
281
|
+
process.stderr.write(chalk.red(`${encryptionError}\n`));
|
|
215
282
|
process.exit(1);
|
|
216
283
|
}
|
|
217
284
|
let uploaded = 0;
|
|
@@ -234,7 +301,16 @@ export async function uploadToR2(header, records) {
|
|
|
234
301
|
uploaded++;
|
|
235
302
|
}
|
|
236
303
|
process.stderr.write(chalk.green(`Backed up ${header.sessions} session${header.sessions === 1 ? '' : 's'} ` +
|
|
237
|
-
`(${uploaded} object${uploaded === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ', UNENCRYPTED'}) →
|
|
304
|
+
`(${uploaded} object${uploaded === 1 ? '' : 's'}${header.encrypted ? ', encrypted' : ', UNENCRYPTED'}) → ` +
|
|
305
|
+
`${client.kind === 'managed' ? 'managed Phoenix store' : 'R2'}.\n`));
|
|
306
|
+
}
|
|
307
|
+
/** Fail-closed guard at the managed transport boundary, not only at record construction. */
|
|
308
|
+
export function managedUploadEncryptionError(header, records) {
|
|
309
|
+
if (!header.encrypted ||
|
|
310
|
+
records.some(record => !record.encrypted || !isTranscriptEnvelope(record.body))) {
|
|
311
|
+
return 'Managed session backups require AES-256-GCM envelopes; refusing to upload plaintext.';
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
238
314
|
}
|
|
239
315
|
/** R2 object key for one record — dir-shaped agents key by relKey, file-shaped by session. */
|
|
240
316
|
export function r2KeyForRecord(rec) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import { type SessionsBackupClient } from '../lib/session/sync/net-client.js';
|
|
2
3
|
import { type ParsedBundle } from '../lib/session/bundle.js';
|
|
3
4
|
export declare function registerSessionsImportCommand(sessionsCmd: Command): void;
|
|
4
5
|
/**
|
|
@@ -16,4 +17,4 @@ export declare function registerSessionsImportCommand(sessionsCmd: Command): voi
|
|
|
16
17
|
* the command and its test drive the same decision.
|
|
17
18
|
*/
|
|
18
19
|
export declare function r2ImportGateError(fromR2: boolean, isConfigured: boolean): string | null;
|
|
19
|
-
export declare function pullFromR2(): Promise<ParsedBundle>;
|
|
20
|
+
export declare function pullFromR2(resolvedClient?: SessionsBackupClient): Promise<ParsedBundle>;
|
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
import * as fs from 'fs';
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import { isSyncConfigured, loadR2Config } from '../lib/session/sync/config.js';
|
|
14
|
-
import { resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
|
|
14
|
+
import { isTranscriptEnvelope, resolveSyncEncKey } from '../lib/session/sync/transcript-crypto.js';
|
|
15
15
|
import { R2Client } from '../lib/session/sync/r2.js';
|
|
16
16
|
import { SESSIONS_PREFIX } from '../lib/session/sync/agents.js';
|
|
17
|
+
import { resolveSessionsBackend } from '../lib/session/sync/backend.js';
|
|
18
|
+
import { SessionsHttpClient } from '../lib/session/sync/net-client.js';
|
|
19
|
+
import { resolveManagedBackupKey } from '../lib/session/sync/managed-key.js';
|
|
17
20
|
import { parseBundle, planImport, writeImport, mergeRecords, makeHeader, } from '../lib/session/bundle.js';
|
|
18
21
|
import { pullBundlesFromHosts } from '../lib/session/remote-bundle.js';
|
|
19
22
|
import { setHelpSections } from '../lib/help.js';
|
|
@@ -25,7 +28,8 @@ export function registerSessionsImportCommand(sessionsCmd) {
|
|
|
25
28
|
.option('--overwrite', 'Replace local files that differ from the bundle (default: keep local)')
|
|
26
29
|
.option('--decrypt [key]', 'Decrypt an encrypted bundle (key optional if the r2.backups sync key is configured)')
|
|
27
30
|
.option('--from-host <target...>', 'Pull sessions live from remote peer(s) over SSH instead of a file (repeatable)')
|
|
28
|
-
.option('--from-r2', 'Restore session backups from
|
|
31
|
+
.option('--from-r2', 'Restore session backups from the off-box store (managed Phoenix store when signed in; your own r2.backups bucket with --byo)')
|
|
32
|
+
.option('--byo', 'With --from-r2: restore from your own r2.backups bucket instead of the managed Phoenix store');
|
|
29
33
|
setHelpSections(cmd, {
|
|
30
34
|
examples: `# Preview what a bundle would restore
|
|
31
35
|
agents sessions import week.bundle --dry-run
|
|
@@ -46,9 +50,12 @@ they show up in 'agents sessions' tagged with that machine and never overwrite
|
|
|
46
50
|
your own local sessions. Byte-exact duplicates are skipped. --from-host reuses
|
|
47
51
|
the same SSH transport as the cross-machine listing (no R2, no daemon).
|
|
48
52
|
|
|
49
|
-
--from-r2 downloads every session backup
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
--from-r2 downloads every session backup from the off-box store and restores it
|
|
54
|
+
through the same placement. When signed in it restores from the MANAGED Phoenix
|
|
55
|
+
store and decrypts with your per-account key (recovered automatically, even on a
|
|
56
|
+
fresh box); --byo restores from your own r2.backups bucket with its shared
|
|
57
|
+
R2_SYNC_ENC_KEY (or pass --decrypt <key>). It is the inverse of
|
|
58
|
+
'sessions export --to-r2'.`,
|
|
52
59
|
});
|
|
53
60
|
cmd.action(async (bundlePath, options, command) => {
|
|
54
61
|
const g = command.optsWithGlobals();
|
|
@@ -58,13 +65,39 @@ pass --decrypt <key>). It is the inverse of 'sessions export --to-r2'.`,
|
|
|
58
65
|
async function runImport(bundlePath, options, g, command) {
|
|
59
66
|
// 1. Obtain the bundle — from R2, remote peer(s), stdin, or a file.
|
|
60
67
|
let bundle;
|
|
68
|
+
// A managed restore recovers the per-account DEK (from the local cache or the
|
|
69
|
+
// Worker escrow) and decrypts with it; BYO falls back to the r2.backups key.
|
|
70
|
+
let managedDecryptKey;
|
|
71
|
+
if (options.byo && !options.fromR2) {
|
|
72
|
+
process.stderr.write(chalk.red('--byo is only valid with --from-r2.\n'));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
61
75
|
if (options.fromR2) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
let client;
|
|
77
|
+
let managedClient;
|
|
78
|
+
let managedUserId;
|
|
79
|
+
try {
|
|
80
|
+
const backend = resolveSessionsBackend({ byo: options.byo });
|
|
81
|
+
if (backend.kind === 'managed') {
|
|
82
|
+
managedClient = new SessionsHttpClient({ baseUrl: backend.baseUrl, userId: backend.userId, token: backend.token });
|
|
83
|
+
client = managedClient;
|
|
84
|
+
managedUserId = backend.userId;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const gateErr = r2ImportGateError(true, isSyncConfigured());
|
|
88
|
+
if (gateErr)
|
|
89
|
+
throw new Error(gateErr);
|
|
90
|
+
client = new R2Client(backend.r2);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
|
|
65
95
|
process.exit(1);
|
|
66
96
|
}
|
|
67
|
-
bundle = await pullFromR2();
|
|
97
|
+
bundle = await pullFromR2(client);
|
|
98
|
+
if (managedUserId) {
|
|
99
|
+
managedDecryptKey = await resolveManagedBackupKey(managedClient, managedUserId);
|
|
100
|
+
}
|
|
68
101
|
}
|
|
69
102
|
else if (options.fromHost && options.fromHost.length > 0) {
|
|
70
103
|
bundle = await pullForImport(options.fromHost, bundlePath, g, command);
|
|
@@ -98,8 +131,12 @@ async function runImport(bundlePath, options, g, command) {
|
|
|
98
131
|
process.exit(1);
|
|
99
132
|
}
|
|
100
133
|
}
|
|
101
|
-
// 3. Resolve the decryption key if the bundle is encrypted.
|
|
102
|
-
|
|
134
|
+
// 3. Resolve the decryption key if the bundle is encrypted. A managed restore
|
|
135
|
+
// already recovered the per-account DEK; otherwise fall back to an explicit
|
|
136
|
+
// --decrypt <key> or the shared r2.backups key.
|
|
137
|
+
const decryptKey = bundle.header.encrypted
|
|
138
|
+
? (managedDecryptKey ?? resolveDecryptKey(options.decrypt))
|
|
139
|
+
: null;
|
|
103
140
|
// 4. Plan.
|
|
104
141
|
let plan;
|
|
105
142
|
try {
|
|
@@ -193,21 +230,33 @@ export function r2ImportGateError(fromR2, isConfigured) {
|
|
|
193
230
|
}
|
|
194
231
|
return null;
|
|
195
232
|
}
|
|
196
|
-
export async function pullFromR2() {
|
|
233
|
+
export async function pullFromR2(resolvedClient) {
|
|
234
|
+
// The resolved client (managed HTTP or BYO R2) is passed in by the command; the
|
|
235
|
+
// no-client overload preserves the original BYO-only signature (loadR2Config)
|
|
236
|
+
// for the direct unit test. The managed client's list() ignores the prefix and
|
|
237
|
+
// enumerates the whole owner namespace, so one call site serves both.
|
|
197
238
|
let client;
|
|
198
239
|
let bucket;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
bucket =
|
|
202
|
-
client = new R2Client(cfg);
|
|
240
|
+
if (resolvedClient) {
|
|
241
|
+
client = resolvedClient;
|
|
242
|
+
bucket = resolvedClient.kind === 'managed' ? 'managed' : 'byo';
|
|
203
243
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
244
|
+
else {
|
|
245
|
+
try {
|
|
246
|
+
const cfg = loadR2Config();
|
|
247
|
+
bucket = cfg.bucket;
|
|
248
|
+
client = new R2Client(cfg);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
process.stderr.write(chalk.red(`R2 restore: ${err.message}\n`));
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
207
254
|
}
|
|
208
255
|
let keys;
|
|
209
256
|
try {
|
|
210
|
-
keys =
|
|
257
|
+
keys = client.kind === 'managed'
|
|
258
|
+
? await client.list()
|
|
259
|
+
: await client.list(SESSIONS_PREFIX);
|
|
211
260
|
}
|
|
212
261
|
catch (err) {
|
|
213
262
|
process.stderr.write(chalk.red(`R2 restore: listing failed: ${err.message}\n`));
|
|
@@ -239,6 +288,19 @@ export async function pullFromR2() {
|
|
|
239
288
|
skipped++;
|
|
240
289
|
continue;
|
|
241
290
|
}
|
|
291
|
+
// Managed transcripts are mandatory AES-256-GCM (SES-51). A plaintext object
|
|
292
|
+
// in the managed store is a contract violation — an older/buggy writer or
|
|
293
|
+
// tampering — so fail loud rather than importing readable plaintext. Check
|
|
294
|
+
// the header AND every record body (matching the Worker's per-record
|
|
295
|
+
// isEncryptedManagedBundle boundary guard), never just the header flag.
|
|
296
|
+
if (client.kind === 'managed') {
|
|
297
|
+
const plaintext = !parsed.header.encrypted
|
|
298
|
+
|| parsed.records.some(rec => !rec.encrypted || !isTranscriptEnvelope(rec.body));
|
|
299
|
+
if (plaintext) {
|
|
300
|
+
process.stderr.write(chalk.red(`R2 restore: managed backup ${key} is not encrypted — managed transcripts are always sealed; refusing to import plaintext.\n`));
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
242
304
|
records.push(...parsed.records);
|
|
243
305
|
if (parsed.header.encrypted)
|
|
244
306
|
encryptedAny = true;
|
|
@@ -250,7 +312,9 @@ export async function pullFromR2() {
|
|
|
250
312
|
}
|
|
251
313
|
const deduped = mergeRecords([records]);
|
|
252
314
|
if (deduped.length === 0) {
|
|
253
|
-
process.stderr.write(chalk.red(
|
|
315
|
+
process.stderr.write(chalk.red(bucket === 'managed'
|
|
316
|
+
? 'No session backups found in the managed store.\n'
|
|
317
|
+
: `No session backups found in R2 bucket '${bucket}'.\n`));
|
|
254
318
|
process.exit(1);
|
|
255
319
|
}
|
|
256
320
|
const header = makeHeader({
|
|
@@ -28,7 +28,7 @@ export interface PublishUsageSnapshotResult {
|
|
|
28
28
|
path: string | null;
|
|
29
29
|
}
|
|
30
30
|
/** Publish this headed device's stable usage snapshot into its owned store file. */
|
|
31
|
-
export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): PublishUsageSnapshotResult
|
|
31
|
+
export declare function publishUsageSnapshotToSharedStore(options?: PublishUsageSnapshotOptions): Promise<PublishUsageSnapshotResult>;
|
|
32
32
|
export interface ConsumeUsageSnapshotsOptions {
|
|
33
33
|
userAgentsDir?: string;
|
|
34
34
|
cachePath?: string;
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* There is deliberately no device-to-device SSH in this module or its tick.
|
|
9
9
|
*/
|
|
10
10
|
import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole } from '../device-config.js';
|
|
11
|
-
import { readFleetSharedDeviceStates,
|
|
11
|
+
import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from '../fleet-shared-state.js';
|
|
12
12
|
import { getUserAgentsDir } from '../state.js';
|
|
13
13
|
import { machineId, normalizeHost } from '../session/sync/config.js';
|
|
14
14
|
import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
|
|
15
15
|
/** Publish this headed device's stable usage snapshot into its owned store file. */
|
|
16
|
-
export function publishUsageSnapshotToSharedStore(options = {}) {
|
|
16
|
+
export async function publishUsageSnapshotToSharedStore(options = {}) {
|
|
17
17
|
const result = {
|
|
18
18
|
published: false,
|
|
19
19
|
changed: false,
|
|
@@ -32,7 +32,7 @@ export function publishUsageSnapshotToSharedStore(options = {}) {
|
|
|
32
32
|
return result;
|
|
33
33
|
}
|
|
34
34
|
try {
|
|
35
|
-
const write =
|
|
35
|
+
const write = await updateFleetSharedDeviceStateAsync(options.device ?? machineId(), { usage: { rows } }, options.userAgentsDir ?? getUserAgentsDir());
|
|
36
36
|
result.published = true;
|
|
37
37
|
result.changed = write.changed;
|
|
38
38
|
result.path = write.path;
|
|
@@ -3,6 +3,16 @@ import { BrowserService } from './service.js';
|
|
|
3
3
|
import type { IPCRequest, IPCResponse } from './types.js';
|
|
4
4
|
export interface IPCRequestOptions {
|
|
5
5
|
autoStartDaemon?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Opt-in client-side deadline in ms — most IPC actions (browser automation,
|
|
8
|
+
* long-running recordings) have no fixed budget and must NOT get one by
|
|
9
|
+
* default. Set explicitly for a request that genuinely has one, e.g.
|
|
10
|
+
* `request-self-update`, whose handler answers immediately (it kicks the
|
|
11
|
+
* install off in the background — see `triggerSelfUpdateInBackground`), so its
|
|
12
|
+
* client deadline (`DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS`) is a short
|
|
13
|
+
* "did the daemon accept the trigger?" bound, not a wait on the install.
|
|
14
|
+
*/
|
|
15
|
+
timeoutMs?: number;
|
|
6
16
|
}
|
|
7
17
|
export declare class BrowserServiceNotRunningError extends Error {
|
|
8
18
|
constructor();
|
|
@@ -148,6 +158,23 @@ export declare class BrowserIPCServer {
|
|
|
148
158
|
* owns the shared process.
|
|
149
159
|
*/
|
|
150
160
|
export declare function shouldRecommendDaemonRefresh(daemonVersion: string | undefined, clientVersion: string): boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Reconcile the running daemon's version with ours without evicting it. If the
|
|
163
|
+
* daemon is serving stale code, ask the daemon to run its OWN self-update path
|
|
164
|
+
* (`request-self-update`, same fail-closed check/install/verify/exit
|
|
165
|
+
* SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
|
|
166
|
+
* telling a human to run `agents daemon restart` by hand. A browser client
|
|
167
|
+
* still owns neither the shared supervisor nor its sibling services, so
|
|
168
|
+
* version skew can never be permission for THIS client to stop or restart
|
|
169
|
+
* that process directly (PHNX-3605 holds) — it only asks the daemon to run a
|
|
170
|
+
* path the daemon already runs on its own schedule, which only exits once the
|
|
171
|
+
* daemon itself has verified the new version is good. If self-update declines
|
|
172
|
+
* (dev build, shadowed install, already current, or a real failure) this
|
|
173
|
+
* degrades to the same "nothing changed" outcome PHNX-3605 shipped.
|
|
174
|
+
*/
|
|
175
|
+
export declare function reconcileDaemonVersion(): Promise<void>;
|
|
176
|
+
/** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
|
|
177
|
+
export declare function resetVersionReconciliationForTest(): void;
|
|
151
178
|
export declare function sendIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
|
|
152
179
|
/**
|
|
153
180
|
* Fill actor / launchId / sessionId from the calling process when the request
|
|
@@ -160,3 +187,10 @@ export declare function stampCallerIdentity(request: IPCRequest, env?: NodeJS.Pr
|
|
|
160
187
|
* only the client process and IPC socket lifetime differ.
|
|
161
188
|
*/
|
|
162
189
|
export declare function connectBrowserIPC(opts?: IPCRequestOptions): Promise<BrowserIPCConnection>;
|
|
190
|
+
/**
|
|
191
|
+
* Exported (rather than kept module-private) so `ipc.test.ts` can drive the
|
|
192
|
+
* `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
|
|
193
|
+
* test double that never responds — the hermetic way to prove the timeout
|
|
194
|
+
* actually fires without needing a slow real IPC action to provoke it.
|
|
195
|
+
*/
|
|
196
|
+
export declare function sendRawIPCRequest(request: IPCRequest, opts?: IPCRequestOptions): Promise<IPCResponse>;
|