@phnx-labs/agents-cli 1.22.69 → 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 +34 -0
- package/README.md +34 -9
- package/dist/bootstrap.js +4 -4
- package/dist/commands/accounts.js +23 -7
- package/dist/commands/exec.js +2 -2
- package/dist/commands/import.js +2 -2
- package/dist/commands/models.js +2 -2
- package/dist/commands/permissions.js +2 -2
- package/dist/commands/repo.js +2 -2
- package/dist/commands/rules.js +1 -1
- package/dist/commands/send.js +9 -3
- 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/commands/traces.js +1 -1
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-registry.d.ts +3 -3
- package/dist/lib/account-registry.js +25 -7
- package/dist/lib/accounting/usage-sync.d.ts +1 -1
- package/dist/lib/accounting/usage-sync.js +3 -3
- package/dist/lib/acp/client.d.ts +1 -1
- package/dist/lib/acp/client.js +12 -1
- package/dist/lib/acp/harnesses.js +1 -1
- package/dist/lib/add-dir.js +0 -2
- package/dist/lib/agent-cli-commands.js +0 -2
- package/dist/lib/agent-spec/agents.d.ts +1 -1
- package/dist/lib/agent-spec/agents.js +2 -83
- package/dist/lib/browser/ipc.d.ts +34 -0
- package/dist/lib/browser/ipc.js +149 -20
- package/dist/lib/browser/remote-control.d.ts +6 -3
- package/dist/lib/browser/remote-control.js +6 -3
- package/dist/lib/browser/service.d.ts +4 -1
- package/dist/lib/browser/service.js +7 -1
- package/dist/lib/browser/types.d.ts +3 -1
- package/dist/lib/channels/registry.d.ts +6 -0
- package/dist/lib/channels/send.d.ts +11 -2
- package/dist/lib/channels/send.js +11 -2
- package/dist/lib/cloud/rush.d.ts +10 -2
- package/dist/lib/cloud/rush.js +15 -9
- 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 +41 -11
- 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 +237 -80
- 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/exec.d.ts +2 -2
- package/dist/lib/exec.js +3 -34
- package/dist/lib/feed/events.d.ts +22 -14
- package/dist/lib/feed/events.js +84 -44
- package/dist/lib/feed-broadcast.d.ts +12 -29
- package/dist/lib/feed-broadcast.js +28 -27
- 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/hooks/install.js +0 -87
- package/dist/lib/hosts/reconcile.d.ts +11 -4
- package/dist/lib/hosts/reconcile.js +31 -5
- package/dist/lib/installations/strategies.js +1 -1
- package/dist/lib/mcp-registry.js +0 -13
- package/dist/lib/mcp.js +2 -2
- package/dist/lib/model-tiers.js +1 -1
- package/dist/lib/models.js +0 -63
- package/dist/lib/notify.d.ts +11 -0
- package/dist/lib/notify.js +17 -4
- package/dist/lib/owner-message.d.ts +46 -3
- package/dist/lib/owner-message.js +26 -6
- package/dist/lib/permissions-registry.d.ts +0 -2
- package/dist/lib/permissions-registry.js +3 -50
- package/dist/lib/permissions.d.ts +3 -17
- package/dist/lib/permissions.js +4 -73
- 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/rush-session.d.ts +19 -0
- package/dist/lib/rush-session.js +24 -0
- package/dist/lib/secrets/drivers/rush.js +2 -1
- 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/cloud.js +2 -1
- 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/sink-format.d.ts +34 -0
- package/dist/lib/sink-format.js +17 -0
- package/dist/lib/staleness/detectors/permissions.js +0 -20
- package/dist/lib/staleness/writers/commands.js +1 -1
- package/dist/lib/staleness/writers/hooks.js +2 -2
- package/dist/lib/subagents-registry.js +2 -12
- package/dist/lib/subagents.d.ts +0 -10
- package/dist/lib/subagents.js +0 -37
- 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/dist/lib/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/lib/self-update.js
CHANGED
|
@@ -12,10 +12,13 @@
|
|
|
12
12
|
* to PATH resolution.
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from 'fs';
|
|
15
|
+
import * as fsp from 'fs/promises';
|
|
15
16
|
import * as os from 'os';
|
|
16
17
|
import * as path from 'path';
|
|
17
18
|
import { createHash, timingSafeEqual } from 'crypto';
|
|
18
|
-
import {
|
|
19
|
+
import { execFile } from 'child_process';
|
|
20
|
+
import { promisify } from 'util';
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
19
22
|
// Leaf comparator only — do not pull the full versions.ts graph into every
|
|
20
23
|
// bootstrap that imports self-update (RUSH-2331).
|
|
21
24
|
import { compareVersions } from './agent-spec/primitives.js';
|
|
@@ -314,7 +317,7 @@ export function deriveGlobalPrefix(packageRoot) {
|
|
|
314
317
|
* basename), so an unrelated dotfile in the same directory is left alone.
|
|
315
318
|
* Best-effort per entry: one unremovable sibling must not block the rest.
|
|
316
319
|
*/
|
|
317
|
-
export function sweepStaleInstallStaging(packageRoot) {
|
|
320
|
+
export async function sweepStaleInstallStaging(packageRoot) {
|
|
318
321
|
const resolved = path.resolve(packageRoot);
|
|
319
322
|
const dir = path.dirname(resolved);
|
|
320
323
|
const base = path.basename(resolved);
|
|
@@ -322,7 +325,7 @@ export function sweepStaleInstallStaging(packageRoot) {
|
|
|
322
325
|
const stagingPattern = new RegExp(`^\\.${escapedBase}-[a-zA-Z0-9]+$`);
|
|
323
326
|
let entries;
|
|
324
327
|
try {
|
|
325
|
-
entries =
|
|
328
|
+
entries = await fsp.readdir(dir);
|
|
326
329
|
}
|
|
327
330
|
catch {
|
|
328
331
|
return [];
|
|
@@ -333,7 +336,7 @@ export function sweepStaleInstallStaging(packageRoot) {
|
|
|
333
336
|
continue;
|
|
334
337
|
const full = path.join(dir, entry);
|
|
335
338
|
try {
|
|
336
|
-
|
|
339
|
+
await fsp.rm(full, { recursive: true, force: true });
|
|
337
340
|
swept.push(full);
|
|
338
341
|
}
|
|
339
342
|
catch {
|
|
@@ -347,14 +350,22 @@ export function sweepStaleInstallStaging(packageRoot) {
|
|
|
347
350
|
* destination no matter which npm binary PATH resolves. `--ignore-scripts`
|
|
348
351
|
* skips lifecycle scripts; the caller refreshes alias shims afterwards via
|
|
349
352
|
* refreshAliasShims().
|
|
353
|
+
*
|
|
354
|
+
* `signal`, when passed, is wired into `execFile`'s own `signal` option —
|
|
355
|
+
* Node kills the child process on abort and the returned promise rejects,
|
|
356
|
+
* rather than the caller merely giving up on awaiting an orphaned process
|
|
357
|
+
* (self-update-service.ts's daemon tick needs a real kill here: on a
|
|
358
|
+
* deadline abort an un-killed `npm install -g` keeps writing into the same
|
|
359
|
+
* global prefix a subsequent retry then installs into concurrently).
|
|
350
360
|
*/
|
|
351
|
-
export async function installPackageIntoPrefix(spec, prefix) {
|
|
361
|
+
export async function installPackageIntoPrefix(spec, prefix, signal) {
|
|
352
362
|
const { execFile } = await import('child_process');
|
|
353
363
|
const { promisify } = await import('util');
|
|
354
364
|
const execFileAsync = promisify(execFile);
|
|
355
365
|
// On Windows `npm` is `npm.cmd`; execFile cannot run it without a shell (ENOENT).
|
|
356
366
|
await execFileAsync('npm', ['install', '-g', '--prefix', prefix, spec, '--ignore-scripts'], {
|
|
357
367
|
shell: needsWindowsShell('npm'),
|
|
368
|
+
signal,
|
|
358
369
|
});
|
|
359
370
|
}
|
|
360
371
|
/**
|
|
@@ -364,8 +375,10 @@ export async function installPackageIntoPrefix(spec, prefix) {
|
|
|
364
375
|
* in place. bun skips untrusted lifecycle scripts, so the caller refreshes
|
|
365
376
|
* alias shims afterwards via refreshAliasShims() rather than relying on the
|
|
366
377
|
* package's postinstall hook.
|
|
378
|
+
*
|
|
379
|
+
* `signal` behaves exactly as documented on {@link installPackageIntoPrefix}.
|
|
367
380
|
*/
|
|
368
|
-
export async function installPackageWithBun(spec) {
|
|
381
|
+
export async function installPackageWithBun(spec, signal) {
|
|
369
382
|
const { execFile } = await import('child_process');
|
|
370
383
|
const { promisify } = await import('util');
|
|
371
384
|
const execFileAsync = promisify(execFile);
|
|
@@ -373,7 +386,7 @@ export async function installPackageWithBun(spec) {
|
|
|
373
386
|
// --ignore-scripts: the tarball has already been integrity-verified, but its
|
|
374
387
|
// lifecycle scripts must not run at install time (the caller refreshes shims
|
|
375
388
|
// explicitly via refreshAliasShims()) — same fail-closed posture as the npm path.
|
|
376
|
-
await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun') });
|
|
389
|
+
await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun'), signal });
|
|
377
390
|
}
|
|
378
391
|
/**
|
|
379
392
|
* Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
|
|
@@ -411,9 +424,14 @@ export function verifyTarballIntegrity(tarball, integrity) {
|
|
|
411
424
|
* against the registry attestation. Fails closed: a non-200, a download error,
|
|
412
425
|
* or a hash mismatch throws and no file path is returned, so the caller never
|
|
413
426
|
* installs an unverified artifact.
|
|
427
|
+
*
|
|
428
|
+
* `signal`, when passed, aborts the fetch alongside the own `timeoutMs` timer
|
|
429
|
+
* (whichever fires first) — a real cancellation of the in-flight request, not
|
|
430
|
+
* just an abandoned await.
|
|
414
431
|
*/
|
|
415
|
-
export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000) {
|
|
416
|
-
const
|
|
432
|
+
export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000, signal) {
|
|
433
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
434
|
+
const response = await fetch(tarballUrl, { signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal });
|
|
417
435
|
if (!response.ok) {
|
|
418
436
|
throw new Error(`could not download tarball from ${tarballUrl} (HTTP ${response.status})`);
|
|
419
437
|
}
|
|
@@ -425,15 +443,15 @@ export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs =
|
|
|
425
443
|
return file;
|
|
426
444
|
}
|
|
427
445
|
/** Read the version field of the package.json at `packageRoot`, fresh from disk. */
|
|
428
|
-
export function readInstalledVersion(packageRoot) {
|
|
429
|
-
return JSON.parse(
|
|
446
|
+
export async function readInstalledVersion(packageRoot) {
|
|
447
|
+
return JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf-8')).version;
|
|
430
448
|
}
|
|
431
449
|
/**
|
|
432
450
|
* Assert that the install at `packageRoot` now carries `expectedVersion`.
|
|
433
451
|
* npm exiting 0 only proves it wrote *somewhere*; this proves it wrote *here*.
|
|
434
452
|
*/
|
|
435
|
-
export function verifyInstalledVersion(packageRoot, expectedVersion) {
|
|
436
|
-
const actual = readInstalledVersion(packageRoot);
|
|
453
|
+
export async function verifyInstalledVersion(packageRoot, expectedVersion) {
|
|
454
|
+
const actual = await readInstalledVersion(packageRoot);
|
|
437
455
|
if (actual !== expectedVersion) {
|
|
438
456
|
const manager = detectPackageManager(packageRoot);
|
|
439
457
|
const hint = manualInstallHint(manager, packageRoot, `${NPM_PACKAGE_NAME}@${expectedVersion}`);
|
|
@@ -448,16 +466,21 @@ export function verifyInstalledVersion(packageRoot, expectedVersion) {
|
|
|
448
466
|
* leaves the previous shims in place, which still point at the (now
|
|
449
467
|
* upgraded) package root.
|
|
450
468
|
*/
|
|
451
|
-
export function refreshAliasShims(packageRoot) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
469
|
+
export async function refreshAliasShims(packageRoot, signal) {
|
|
470
|
+
try {
|
|
471
|
+
await execFileAsync(process.execPath, [path.join(packageRoot, 'scripts', 'postinstall.js')], {
|
|
472
|
+
env: { ...process.env, AGENTS_POSTINSTALL_SHIMS_ONLY: '1' },
|
|
473
|
+
signal,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
/* best-effort — a failure here leaves the previous shims in place, same as the prior spawnSync (which ignored its exit code/stdio too) */
|
|
478
|
+
}
|
|
456
479
|
}
|
|
457
480
|
/** Resolve `p` through symlinks, or null when it does not resolve (missing/dangling). */
|
|
458
|
-
function realpathOrNull(p) {
|
|
481
|
+
async function realpathOrNull(p) {
|
|
459
482
|
try {
|
|
460
|
-
return
|
|
483
|
+
return await fsp.realpath(p);
|
|
461
484
|
}
|
|
462
485
|
catch {
|
|
463
486
|
return null;
|
|
@@ -472,18 +495,18 @@ function realpathOrNull(p) {
|
|
|
472
495
|
* repair that still does not resolve (target missing, unwritable bin dir) is
|
|
473
496
|
* reported `failed` with the reason rather than silently swallowed.
|
|
474
497
|
*/
|
|
475
|
-
function reconcileBinLink(name, linkPath, target) {
|
|
476
|
-
const wanted = realpathOrNull(target);
|
|
477
|
-
if (wanted !== null && realpathOrNull(linkPath) === wanted) {
|
|
498
|
+
async function reconcileBinLink(name, linkPath, target) {
|
|
499
|
+
const wanted = await realpathOrNull(target);
|
|
500
|
+
if (wanted !== null && (await realpathOrNull(linkPath)) === wanted) {
|
|
478
501
|
return { name, linkPath, target, action: 'ok' };
|
|
479
502
|
}
|
|
480
503
|
try {
|
|
481
|
-
|
|
504
|
+
await fsp.mkdir(path.dirname(linkPath), { recursive: true });
|
|
482
505
|
// Replace whatever is there (a dangling link, a stale link, or nothing).
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const resolved = realpathOrNull(linkPath);
|
|
486
|
-
if (resolved !== null && resolved === realpathOrNull(target)) {
|
|
506
|
+
await fsp.rm(linkPath, { force: true });
|
|
507
|
+
await fsp.symlink(path.relative(path.dirname(linkPath), target), linkPath);
|
|
508
|
+
const resolved = await realpathOrNull(linkPath);
|
|
509
|
+
if (resolved !== null && resolved === (await realpathOrNull(target))) {
|
|
487
510
|
return { name, linkPath, target, action: 'repaired' };
|
|
488
511
|
}
|
|
489
512
|
return {
|
|
@@ -524,10 +547,10 @@ function reconcileBinLink(name, linkPath, target) {
|
|
|
524
547
|
* npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
|
|
525
548
|
* bin layout and is out of scope.
|
|
526
549
|
*/
|
|
527
|
-
export function ensureGlobalBinLinks(packageRoot, prefix) {
|
|
550
|
+
export async function ensureGlobalBinLinks(packageRoot, prefix) {
|
|
528
551
|
let bin;
|
|
529
552
|
try {
|
|
530
|
-
const pkg = JSON.parse(
|
|
553
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
|
|
531
554
|
bin = pkg && typeof pkg.bin === 'object' && pkg.bin !== null ? pkg.bin : {};
|
|
532
555
|
}
|
|
533
556
|
catch (err) {
|
|
@@ -538,7 +561,7 @@ export function ensureGlobalBinLinks(packageRoot, prefix) {
|
|
|
538
561
|
for (const [name, rel] of Object.entries(bin)) {
|
|
539
562
|
if (typeof rel !== 'string' || !rel)
|
|
540
563
|
continue;
|
|
541
|
-
repairs.push(reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
|
|
564
|
+
repairs.push(await reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
|
|
542
565
|
}
|
|
543
566
|
return repairs;
|
|
544
567
|
}
|
|
@@ -15,6 +15,7 @@ import * as os from 'os';
|
|
|
15
15
|
import * as yaml from 'yaml';
|
|
16
16
|
import { deriveShortId } from '../text/short-id.js';
|
|
17
17
|
import { getCacheDir } from '../state.js';
|
|
18
|
+
import { isRushSessionExpired } from '../rush-session.js';
|
|
18
19
|
const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
|
|
19
20
|
const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
20
21
|
const CLOUD_CACHE_DIR = path.join(getCacheDir(), 'cloud-runs');
|
|
@@ -30,7 +31,7 @@ function readToken() {
|
|
|
30
31
|
throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
|
|
31
32
|
}
|
|
32
33
|
const expiresAt = data.session?.expires_at;
|
|
33
|
-
if (
|
|
34
|
+
if (isRushSessionExpired(expiresAt)) {
|
|
34
35
|
const expiredAt = new Date(expiresAt * 1000).toISOString();
|
|
35
36
|
throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
|
|
36
37
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionsBackend — the token-source seam for `agents sessions export --to-r2` /
|
|
3
|
+
* `import --from-r2`.
|
|
4
|
+
*
|
|
5
|
+
* Two legitimate principals, picked once at backup time through the ONE shared
|
|
6
|
+
* managed-vs-BYO policy (`selectStorageBackendKind`):
|
|
7
|
+
*
|
|
8
|
+
* - **managed**: the caller is signed in to Phoenix (`readSession()`), no
|
|
9
|
+
* explicit BYO override. Token is the Phoenix access_token; endpoint is the
|
|
10
|
+
* managed `sessions.agents-cli.sh` Worker; the namespace is the verified
|
|
11
|
+
* userId. ZERO Cloudflare setup — the user never provisions an r2.backups
|
|
12
|
+
* bucket. This is the whole point of the surface (the sessions analogue of
|
|
13
|
+
* managed `agents traces sync`).
|
|
14
|
+
* - **byo**: the existing `r2.backups` secrets bundle (`loadR2Config()`).
|
|
15
|
+
* Unchanged for power users / self-hosters / a zero-knowledge backup that
|
|
16
|
+
* Phoenix can never read.
|
|
17
|
+
*
|
|
18
|
+
* MANAGED-FIRST — the mere PRESENCE of an r2.backups bundle is NOT a BYO
|
|
19
|
+
* override. A signed-in user with a stale r2.backups bundle still backs up to
|
|
20
|
+
* managed unless they opt out explicitly: `--byo`, `AGENTS_SESSIONS_BACKEND=byo`,
|
|
21
|
+
* or a DI write token. This mirrors `lib/share/backend.ts` (a persisted BYO
|
|
22
|
+
* config is deliberately not an override) so the product's managed-first
|
|
23
|
+
* contract is identical across surfaces.
|
|
24
|
+
*/
|
|
25
|
+
import { type PhoenixSession } from '../../identity/client.js';
|
|
26
|
+
import { type R2Config } from './config.js';
|
|
27
|
+
export type SessionsBackendKind = 'managed' | 'byo';
|
|
28
|
+
/** Env var that forces the BYO path. Value must be exactly `byo`. */
|
|
29
|
+
export declare const SESSIONS_BACKEND_ENV = "AGENTS_SESSIONS_BACKEND";
|
|
30
|
+
export interface ManagedSessionsBackend {
|
|
31
|
+
kind: 'managed';
|
|
32
|
+
/** Public base URL of the managed sessions Worker, no trailing slash. */
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
/** Bearer sent as `Authorization`. The Phoenix access_token. */
|
|
35
|
+
token: string;
|
|
36
|
+
/** Phoenix userId — the object-store namespace prefix (path segment 0). */
|
|
37
|
+
userId: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ByoSessionsBackend {
|
|
40
|
+
kind: 'byo';
|
|
41
|
+
/** The resolved r2.backups credentials for the S3-compatible client. */
|
|
42
|
+
r2: R2Config;
|
|
43
|
+
}
|
|
44
|
+
export type SessionsBackend = ManagedSessionsBackend | ByoSessionsBackend;
|
|
45
|
+
export interface ResolveSessionsBackendOpts {
|
|
46
|
+
/** Force the BYO r2.backups path even when signed in. */
|
|
47
|
+
byo?: boolean;
|
|
48
|
+
/** DI seam — a static write token selects BYO (tests / self-host). */
|
|
49
|
+
writeToken?: string;
|
|
50
|
+
/** DI seam — override `readSession()`. `null` means explicitly signed out. */
|
|
51
|
+
session?: PhoenixSession | null;
|
|
52
|
+
}
|
|
53
|
+
/** True when the shared policy resolves to the managed principal for sessions. */
|
|
54
|
+
export declare function shouldUseManagedSessions(opts?: ResolveSessionsBackendOpts): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the backend for a session backup / restore. Managed when signed in and
|
|
57
|
+
* no explicit BYO override; otherwise BYO. Fails loud when neither principal can
|
|
58
|
+
* authenticate — the actionable message ("run auth login" or "add r2.backups")
|
|
59
|
+
* lives here, not in the shared policy.
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveSessionsBackend(opts?: ResolveSessionsBackendOpts): SessionsBackend;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionsBackend — the token-source seam for `agents sessions export --to-r2` /
|
|
3
|
+
* `import --from-r2`.
|
|
4
|
+
*
|
|
5
|
+
* Two legitimate principals, picked once at backup time through the ONE shared
|
|
6
|
+
* managed-vs-BYO policy (`selectStorageBackendKind`):
|
|
7
|
+
*
|
|
8
|
+
* - **managed**: the caller is signed in to Phoenix (`readSession()`), no
|
|
9
|
+
* explicit BYO override. Token is the Phoenix access_token; endpoint is the
|
|
10
|
+
* managed `sessions.agents-cli.sh` Worker; the namespace is the verified
|
|
11
|
+
* userId. ZERO Cloudflare setup — the user never provisions an r2.backups
|
|
12
|
+
* bucket. This is the whole point of the surface (the sessions analogue of
|
|
13
|
+
* managed `agents traces sync`).
|
|
14
|
+
* - **byo**: the existing `r2.backups` secrets bundle (`loadR2Config()`).
|
|
15
|
+
* Unchanged for power users / self-hosters / a zero-knowledge backup that
|
|
16
|
+
* Phoenix can never read.
|
|
17
|
+
*
|
|
18
|
+
* MANAGED-FIRST — the mere PRESENCE of an r2.backups bundle is NOT a BYO
|
|
19
|
+
* override. A signed-in user with a stale r2.backups bundle still backs up to
|
|
20
|
+
* managed unless they opt out explicitly: `--byo`, `AGENTS_SESSIONS_BACKEND=byo`,
|
|
21
|
+
* or a DI write token. This mirrors `lib/share/backend.ts` (a persisted BYO
|
|
22
|
+
* config is deliberately not an override) so the product's managed-first
|
|
23
|
+
* contract is identical across surfaces.
|
|
24
|
+
*/
|
|
25
|
+
import { readSession } from '../../identity/client.js';
|
|
26
|
+
import { selectStorageBackendKind } from '../../storage/selection.js';
|
|
27
|
+
import { loadR2Config } from './config.js';
|
|
28
|
+
import { managedSessionsBaseUrl } from './managed-config.js';
|
|
29
|
+
/** Env var that forces the BYO path. Value must be exactly `byo`. */
|
|
30
|
+
export const SESSIONS_BACKEND_ENV = 'AGENTS_SESSIONS_BACKEND';
|
|
31
|
+
/**
|
|
32
|
+
* The sessions surface's BYO-override signals: an explicit `--byo`, a
|
|
33
|
+
* caller-supplied static write token, or `AGENTS_SESSIONS_BACKEND=byo`. Detecting
|
|
34
|
+
* WHICH signals count is surface-specific; the managed-vs-BYO decision itself is
|
|
35
|
+
* the shared policy. A persisted r2.backups bundle is deliberately NOT an
|
|
36
|
+
* override — a signed-in user still backs up to managed unless they opt out.
|
|
37
|
+
*/
|
|
38
|
+
function sessionsByoOverride(opts) {
|
|
39
|
+
if (opts.byo === true)
|
|
40
|
+
return true;
|
|
41
|
+
if (opts.writeToken)
|
|
42
|
+
return true;
|
|
43
|
+
return (process.env[SESSIONS_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo';
|
|
44
|
+
}
|
|
45
|
+
/** True when the shared policy resolves to the managed principal for sessions. */
|
|
46
|
+
export function shouldUseManagedSessions(opts = {}) {
|
|
47
|
+
return (selectStorageBackendKind({ byoOverride: sessionsByoOverride(opts), session: opts.session }) ===
|
|
48
|
+
'managed');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the backend for a session backup / restore. Managed when signed in and
|
|
52
|
+
* no explicit BYO override; otherwise BYO. Fails loud when neither principal can
|
|
53
|
+
* authenticate — the actionable message ("run auth login" or "add r2.backups")
|
|
54
|
+
* lives here, not in the shared policy.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveSessionsBackend(opts = {}) {
|
|
57
|
+
// Resolve identity ONCE. Reading it for selection and then again for the
|
|
58
|
+
// backend creates a race where logout can flip the principal mid-preflight.
|
|
59
|
+
const session = opts.session === undefined ? readSession() : opts.session;
|
|
60
|
+
const explicitByo = sessionsByoOverride(opts);
|
|
61
|
+
if (selectStorageBackendKind({ byoOverride: explicitByo, session }) === 'managed') {
|
|
62
|
+
if (!session) {
|
|
63
|
+
throw new Error("Not signed in. Run 'agents auth login' to back up sessions to your Phoenix account.");
|
|
64
|
+
}
|
|
65
|
+
if (!session.access_token) {
|
|
66
|
+
throw new Error("Session has no access token. Run 'agents auth login' again.");
|
|
67
|
+
}
|
|
68
|
+
const userId = (session.userId ?? '').trim();
|
|
69
|
+
if (!userId) {
|
|
70
|
+
throw new Error("Signed in but the session has no user id. Run 'agents auth login' again.");
|
|
71
|
+
}
|
|
72
|
+
return { kind: 'managed', baseUrl: managedSessionsBaseUrl(), token: session.access_token, userId };
|
|
73
|
+
}
|
|
74
|
+
// BYO: the existing r2.backups bundle. loadR2Config throws an actionable error
|
|
75
|
+
// when the bundle is missing or locked.
|
|
76
|
+
try {
|
|
77
|
+
return { kind: 'byo', r2: loadR2Config() };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// A user who is simply signed out (not an explicit --byo) and has no bundle
|
|
81
|
+
// should hear about the zero-setup managed path FIRST, then the BYO one.
|
|
82
|
+
if (!explicitByo) {
|
|
83
|
+
throw new Error("Not signed in, and no r2.backups bucket is configured. Run 'agents auth login' to " +
|
|
84
|
+
'back up to the managed Phoenix store (zero setup), or add the r2.backups bundle to ' +
|
|
85
|
+
'use your own bucket: agents secrets add r2.backups R2_ACCOUNT_ID R2_BUCKET_NAME R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY');
|
|
86
|
+
}
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated Cloudflare resource choices for the managed session-backup store.
|
|
3
|
+
*
|
|
4
|
+
* The managed sessions backend is the Phoenix-gated, zero-setup path behind
|
|
5
|
+
* `agents sessions export --to-r2` / `import --from-r2` for a signed-in user —
|
|
6
|
+
* the sessions analogue of the managed `agents traces sync` store. A signed-in
|
|
7
|
+
* user never has to provision their own `r2.backups` bucket; the CLI talks to
|
|
8
|
+
* this already-live Worker under their Phoenix bearer.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `lib/traces/config.ts` + `managedTracesBaseUrl()`. The endpoint host
|
|
11
|
+
* is its own subdomain (a separate Worker + bucket from traces/share), so the
|
|
12
|
+
* blast radius of a bug or a quota exhaustion is one surface, not three.
|
|
13
|
+
*/
|
|
14
|
+
/** Managed sessions Worker domain — its own subdomain, separate from traces/share. */
|
|
15
|
+
export declare const DEFAULT_SESSIONS_DOMAIN = "sessions.agents-cli.sh";
|
|
16
|
+
/** Cloudflare Worker name for the managed sessions deployment. */
|
|
17
|
+
export declare const DEFAULT_SESSIONS_WORKER_NAME = "agents-sessions";
|
|
18
|
+
/** R2 bucket name backing the managed sessions Worker. */
|
|
19
|
+
export declare const DEFAULT_SESSIONS_BUCKET_NAME = "agents-sessions";
|
|
20
|
+
/** Isolated Cloudflare resources for the managed session-backup store. */
|
|
21
|
+
export interface SessionsConfig {
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
accountId: string;
|
|
24
|
+
workerName: string;
|
|
25
|
+
bucketName: string;
|
|
26
|
+
domain?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Public base URL of the managed sessions Worker, no trailing slash. */
|
|
29
|
+
export declare function managedSessionsBaseUrl(): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated Cloudflare resource choices for the managed session-backup store.
|
|
3
|
+
*
|
|
4
|
+
* The managed sessions backend is the Phoenix-gated, zero-setup path behind
|
|
5
|
+
* `agents sessions export --to-r2` / `import --from-r2` for a signed-in user —
|
|
6
|
+
* the sessions analogue of the managed `agents traces sync` store. A signed-in
|
|
7
|
+
* user never has to provision their own `r2.backups` bucket; the CLI talks to
|
|
8
|
+
* this already-live Worker under their Phoenix bearer.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `lib/traces/config.ts` + `managedTracesBaseUrl()`. The endpoint host
|
|
11
|
+
* is its own subdomain (a separate Worker + bucket from traces/share), so the
|
|
12
|
+
* blast radius of a bug or a quota exhaustion is one surface, not three.
|
|
13
|
+
*/
|
|
14
|
+
/** Managed sessions Worker domain — its own subdomain, separate from traces/share. */
|
|
15
|
+
export const DEFAULT_SESSIONS_DOMAIN = 'sessions.agents-cli.sh';
|
|
16
|
+
/** Cloudflare Worker name for the managed sessions deployment. */
|
|
17
|
+
export const DEFAULT_SESSIONS_WORKER_NAME = 'agents-sessions';
|
|
18
|
+
/** R2 bucket name backing the managed sessions Worker. */
|
|
19
|
+
export const DEFAULT_SESSIONS_BUCKET_NAME = 'agents-sessions';
|
|
20
|
+
/** Public base URL of the managed sessions Worker, no trailing slash. */
|
|
21
|
+
export function managedSessionsBaseUrl() {
|
|
22
|
+
return `https://${DEFAULT_SESSIONS_DOMAIN}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed backup encryption key (DEK) — mint, cache, escrow, recover.
|
|
3
|
+
*
|
|
4
|
+
* On the MANAGED path, a session backup is NEVER uploaded in plaintext. Every
|
|
5
|
+
* transcript body is sealed with AES-256-GCM (the same `transcript-crypto.ts`
|
|
6
|
+
* primitives BYO uses) under a 32-byte data-encryption key (DEK) minted per
|
|
7
|
+
* Phoenix user.
|
|
8
|
+
*
|
|
9
|
+
* Where the DEK lives, and the honest trust boundary:
|
|
10
|
+
* - **Local cache** at `~/.agents/.cache/state/sessions-backup-key.json`
|
|
11
|
+
* (mode 0600), a `{ [userId]: <base64-dek> }` map so several Phoenix
|
|
12
|
+
* accounts on one box stay isolated. The Worker escrow remains authoritative.
|
|
13
|
+
* - **Escrow** at the bearer-gated Worker key `<userId>/__key/backup-dek`, so
|
|
14
|
+
* a FRESH box that signs in with the same Phoenix account recovers the DEK
|
|
15
|
+
* with zero setup and can decrypt its own prior backups.
|
|
16
|
+
*
|
|
17
|
+
* Trust boundary (documented honestly — SES-51):
|
|
18
|
+
* - Confidential vs a raw R2 / Cloudflare bucket read: objects at rest are
|
|
19
|
+
* ciphertext envelopes (SES-24 holds), and the DEK escrow object is itself
|
|
20
|
+
* only reachable with the owner's bearer.
|
|
21
|
+
* - NOT zero-knowledge vs Phoenix: the DEK is escrowed on Phoenix-operated
|
|
22
|
+
* infrastructure, so the operator *can* recover the key and read a backup.
|
|
23
|
+
* A user who needs a key Phoenix can never see uses BYO (`--byo`), which
|
|
24
|
+
* keeps the DEK only in their own `r2.backups` bundle — that is the
|
|
25
|
+
* zero-knowledge path.
|
|
26
|
+
*/
|
|
27
|
+
import type { ManagedSessionsBackupClient } from './net-client.js';
|
|
28
|
+
/** Worker key (relative to the `<userId>/` namespace prefix the client prepends). */
|
|
29
|
+
export declare const ESCROW_REL_KEY = "__key/backup-dek";
|
|
30
|
+
/** Local per-user DEK cache file — a `{ [userId]: base64 }` map, mode 0600. */
|
|
31
|
+
export declare function backupKeyCachePath(): string;
|
|
32
|
+
/** The DEK cached locally for this Phoenix user, or null. */
|
|
33
|
+
export declare function readCachedBackupKey(userId: string): Buffer | null;
|
|
34
|
+
/** Persist a DEK (base64) for this Phoenix user in the local 0600 cache. */
|
|
35
|
+
export declare function cacheBackupKey(userId: string, b64: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the managed backup DEK for `userId`, minting + escrowing one on first
|
|
38
|
+
* use. NEVER returns null — the managed path must not upload plaintext.
|
|
39
|
+
*
|
|
40
|
+
* The escrow is authoritative. On a missing escrow, the local cache (if any) is
|
|
41
|
+
* restored with a conditional create; otherwise a new key is minted. Concurrent
|
|
42
|
+
* first-use devices race that create, then every loser reads the one winner
|
|
43
|
+
* before encrypting anything, so no backup can be orphaned under a losing DEK.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveManagedBackupKey(client: ManagedSessionsBackupClient, userId: string): Promise<Buffer>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed backup encryption key (DEK) — mint, cache, escrow, recover.
|
|
3
|
+
*
|
|
4
|
+
* On the MANAGED path, a session backup is NEVER uploaded in plaintext. Every
|
|
5
|
+
* transcript body is sealed with AES-256-GCM (the same `transcript-crypto.ts`
|
|
6
|
+
* primitives BYO uses) under a 32-byte data-encryption key (DEK) minted per
|
|
7
|
+
* Phoenix user.
|
|
8
|
+
*
|
|
9
|
+
* Where the DEK lives, and the honest trust boundary:
|
|
10
|
+
* - **Local cache** at `~/.agents/.cache/state/sessions-backup-key.json`
|
|
11
|
+
* (mode 0600), a `{ [userId]: <base64-dek> }` map so several Phoenix
|
|
12
|
+
* accounts on one box stay isolated. The Worker escrow remains authoritative.
|
|
13
|
+
* - **Escrow** at the bearer-gated Worker key `<userId>/__key/backup-dek`, so
|
|
14
|
+
* a FRESH box that signs in with the same Phoenix account recovers the DEK
|
|
15
|
+
* with zero setup and can decrypt its own prior backups.
|
|
16
|
+
*
|
|
17
|
+
* Trust boundary (documented honestly — SES-51):
|
|
18
|
+
* - Confidential vs a raw R2 / Cloudflare bucket read: objects at rest are
|
|
19
|
+
* ciphertext envelopes (SES-24 holds), and the DEK escrow object is itself
|
|
20
|
+
* only reachable with the owner's bearer.
|
|
21
|
+
* - NOT zero-knowledge vs Phoenix: the DEK is escrowed on Phoenix-operated
|
|
22
|
+
* infrastructure, so the operator *can* recover the key and read a backup.
|
|
23
|
+
* A user who needs a key Phoenix can never see uses BYO (`--byo`), which
|
|
24
|
+
* keeps the DEK only in their own `r2.backups` bundle — that is the
|
|
25
|
+
* zero-knowledge path.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from 'fs';
|
|
28
|
+
import * as path from 'path';
|
|
29
|
+
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from '../../fs-atomic.js';
|
|
30
|
+
import { getRuntimeStateDir } from '../../state.js';
|
|
31
|
+
import { generateSyncEncKey } from './transcript-crypto.js';
|
|
32
|
+
const KEY_LEN = 32; // AES-256
|
|
33
|
+
/** Worker key (relative to the `<userId>/` namespace prefix the client prepends). */
|
|
34
|
+
export const ESCROW_REL_KEY = '__key/backup-dek';
|
|
35
|
+
/** Local per-user DEK cache file — a `{ [userId]: base64 }` map, mode 0600. */
|
|
36
|
+
export function backupKeyCachePath() {
|
|
37
|
+
return path.join(getRuntimeStateDir(), 'sessions-backup-key.json');
|
|
38
|
+
}
|
|
39
|
+
function readCache() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(backupKeyCachePath(), 'utf-8');
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
const cache = Object.create(null);
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
45
|
+
return cache;
|
|
46
|
+
for (const [userId, value] of Object.entries(parsed)) {
|
|
47
|
+
if (typeof value === 'string')
|
|
48
|
+
cache[userId] = value;
|
|
49
|
+
}
|
|
50
|
+
return cache;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return Object.create(null);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Decode a base64/hex DEK into a 32-byte key, or null when it is malformed. */
|
|
57
|
+
function decodeDek(raw) {
|
|
58
|
+
const s = (raw ?? '').trim();
|
|
59
|
+
if (!s)
|
|
60
|
+
return null;
|
|
61
|
+
const key = /^[0-9a-f]{64}$/i.test(s) ? Buffer.from(s, 'hex') : Buffer.from(s, 'base64');
|
|
62
|
+
return key.length === KEY_LEN ? key : null;
|
|
63
|
+
}
|
|
64
|
+
/** The DEK cached locally for this Phoenix user, or null. */
|
|
65
|
+
export function readCachedBackupKey(userId) {
|
|
66
|
+
return decodeDek(readCache()[userId]);
|
|
67
|
+
}
|
|
68
|
+
/** Persist a DEK (base64) for this Phoenix user in the local 0600 cache. */
|
|
69
|
+
export function cacheBackupKey(userId, b64) {
|
|
70
|
+
const file = backupKeyCachePath();
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
72
|
+
ensureLockTarget(file, '{}', 0o700);
|
|
73
|
+
fs.chmodSync(file, 0o600);
|
|
74
|
+
withFileLock(file, () => {
|
|
75
|
+
const cache = readCache();
|
|
76
|
+
cache[userId] = b64;
|
|
77
|
+
atomicWriteFileSync(file, JSON.stringify(cache, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
78
|
+
fs.chmodSync(file, 0o600);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function parseEscrow(body, expectedUserId) {
|
|
82
|
+
try {
|
|
83
|
+
const obj = JSON.parse(body);
|
|
84
|
+
if (obj && obj.v === 1 && obj.userId === expectedUserId &&
|
|
85
|
+
typeof obj.dek === 'string')
|
|
86
|
+
return decodeDek(obj.dek);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* not an escrow envelope */
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the managed backup DEK for `userId`, minting + escrowing one on first
|
|
95
|
+
* use. NEVER returns null — the managed path must not upload plaintext.
|
|
96
|
+
*
|
|
97
|
+
* The escrow is authoritative. On a missing escrow, the local cache (if any) is
|
|
98
|
+
* restored with a conditional create; otherwise a new key is minted. Concurrent
|
|
99
|
+
* first-use devices race that create, then every loser reads the one winner
|
|
100
|
+
* before encrypting anything, so no backup can be orphaned under a losing DEK.
|
|
101
|
+
*/
|
|
102
|
+
export async function resolveManagedBackupKey(client, userId) {
|
|
103
|
+
const escrowed = await client.get(ESCROW_REL_KEY);
|
|
104
|
+
if (escrowed !== null) {
|
|
105
|
+
const key = parseEscrow(escrowed, userId);
|
|
106
|
+
if (key) {
|
|
107
|
+
cacheBackupKey(userId, key.toString('base64'));
|
|
108
|
+
return key;
|
|
109
|
+
}
|
|
110
|
+
throw new Error('Managed session backup key escrow is corrupt or belongs to another account; ' +
|
|
111
|
+
'refusing to replace it because that would orphan existing encrypted backups.');
|
|
112
|
+
}
|
|
113
|
+
const cached = readCachedBackupKey(userId);
|
|
114
|
+
const b64 = cached?.toString('base64') ?? generateSyncEncKey();
|
|
115
|
+
const envelope = { v: 1, userId, dek: b64 };
|
|
116
|
+
const created = await client.putIfAbsent(ESCROW_REL_KEY, JSON.stringify(envelope), 'application/json');
|
|
117
|
+
if (created) {
|
|
118
|
+
cacheBackupKey(userId, b64);
|
|
119
|
+
return Buffer.from(b64, 'base64');
|
|
120
|
+
}
|
|
121
|
+
const winnerBody = await client.get(ESCROW_REL_KEY);
|
|
122
|
+
const winner = winnerBody === null ? null : parseEscrow(winnerBody, userId);
|
|
123
|
+
if (!winner) {
|
|
124
|
+
throw new Error('Managed session backup key escrow was contended but no valid winning key could be recovered.');
|
|
125
|
+
}
|
|
126
|
+
cacheBackupKey(userId, winner.toString('base64'));
|
|
127
|
+
return winner;
|
|
128
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionsHttpClient — the managed transport for `agents sessions export
|
|
3
|
+
* --to-r2` / `import --from-r2` when the caller is signed in to Phoenix.
|
|
4
|
+
*
|
|
5
|
+
* Same put / get / list / delete surface as `R2Client` (`./r2.ts`), so the
|
|
6
|
+
* export/import command drives EITHER a managed HTTP client or a BYO S3 client
|
|
7
|
+
* through the one shared {@link SessionsBackupClient} interface. The difference
|
|
8
|
+
* is only the wire: this talks to the managed sessions Worker over `fetch` with
|
|
9
|
+
* a `Authorization: Bearer <phoenix-token>` header (the same request shape as
|
|
10
|
+
* `traces/sync.ts`), instead of SigV4 against R2 directly.
|
|
11
|
+
*
|
|
12
|
+
* The userId namespace prefix is prepended INSIDE this client
|
|
13
|
+
* (`${baseUrl}/${userId}/${key}`), so the caller passes the SAME object key it
|
|
14
|
+
* passes to `R2Client` (`sessions/<machine>/<agent>/<sessionId>.jsonl`). That
|
|
15
|
+
* keeps the BYO object layout (SES-27a) byte-identical while the managed Worker
|
|
16
|
+
* gets its required `segments[0] === userId` prefix.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The verbs the session backup/restore path needs, satisfied by BOTH the
|
|
20
|
+
* managed {@link SessionsHttpClient} and the BYO `R2Client`. `list` takes an
|
|
21
|
+
* optional prefix: BYO passes the `sessions/` prefix; managed ignores it and
|
|
22
|
+
* enumerates the whole owner namespace server-side.
|
|
23
|
+
*/
|
|
24
|
+
export interface SessionsBackupClient {
|
|
25
|
+
readonly kind: 'managed' | 'byo';
|
|
26
|
+
put(key: string, body: string | Uint8Array, contentType?: string): Promise<void>;
|
|
27
|
+
get(key: string): Promise<string | null>;
|
|
28
|
+
list(prefix?: string): Promise<string[]>;
|
|
29
|
+
delete(key: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** Managed-only extension used to establish an immutable escrow key. */
|
|
32
|
+
export interface ManagedSessionsBackupClient extends SessionsBackupClient {
|
|
33
|
+
readonly kind: 'managed';
|
|
34
|
+
putIfAbsent(key: string, body: string | Uint8Array, contentType?: string): Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
export declare class SessionsHttpClient implements ManagedSessionsBackupClient {
|
|
37
|
+
readonly kind: "managed";
|
|
38
|
+
private base;
|
|
39
|
+
private userId;
|
|
40
|
+
private token;
|
|
41
|
+
constructor(opts: {
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
userId: string;
|
|
44
|
+
token: string;
|
|
45
|
+
});
|
|
46
|
+
/** `${baseUrl}/<userId>/<key>` with every path segment percent-encoded. */
|
|
47
|
+
private objUrl;
|
|
48
|
+
private authHeaders;
|
|
49
|
+
/** Upload an object under this owner's namespace. Overwrites unconditionally. */
|
|
50
|
+
put(key: string, body: string | Uint8Array, contentType?: string): Promise<void>;
|
|
51
|
+
/** Create an object exactly once. A concurrent winner returns false. */
|
|
52
|
+
putIfAbsent(key: string, body: string | Uint8Array, contentType?: string): Promise<boolean>;
|
|
53
|
+
/** Fetch an object as text, or null if it does not exist (404). */
|
|
54
|
+
get(key: string): Promise<string | null>;
|
|
55
|
+
/**
|
|
56
|
+
* List this owner's object keys, each relative to the `<userId>/` prefix (so
|
|
57
|
+
* they feed straight back into `get`/`delete`). The `prefix` arg is accepted
|
|
58
|
+
* for interface parity with `R2Client` but ignored — the managed Worker
|
|
59
|
+
* enumerates the whole owner namespace and excludes the reserved `__` keys
|
|
60
|
+
* server-side.
|
|
61
|
+
*/
|
|
62
|
+
list(_prefix?: string): Promise<string[]>;
|
|
63
|
+
/** Delete an object (the Worker refunds its bytes to the quota ledger). */
|
|
64
|
+
delete(key: string): Promise<void>;
|
|
65
|
+
}
|