@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
export class SessionsHttpClient {
|
|
19
|
+
kind = 'managed';
|
|
20
|
+
base;
|
|
21
|
+
userId;
|
|
22
|
+
token;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.base = opts.baseUrl.replace(/\/+$/, '');
|
|
25
|
+
this.userId = safeOwnerSegment(opts.userId);
|
|
26
|
+
this.token = opts.token;
|
|
27
|
+
}
|
|
28
|
+
/** `${baseUrl}/<userId>/<key>` with every path segment percent-encoded. */
|
|
29
|
+
objUrl(key) {
|
|
30
|
+
const rel = safeObjectKey(key).map(encodeURIComponent).join('/');
|
|
31
|
+
return `${this.base}/${encodeURIComponent(this.userId)}/${rel}`;
|
|
32
|
+
}
|
|
33
|
+
authHeaders(extra = {}) {
|
|
34
|
+
return { authorization: `Bearer ${this.token}`, ...extra };
|
|
35
|
+
}
|
|
36
|
+
/** Upload an object under this owner's namespace. Overwrites unconditionally. */
|
|
37
|
+
async put(key, body, contentType = 'application/octet-stream') {
|
|
38
|
+
const res = await fetch(this.objUrl(key), {
|
|
39
|
+
method: 'PUT',
|
|
40
|
+
headers: this.authHeaders({ 'content-type': contentType }),
|
|
41
|
+
body,
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error(`sessions PUT ${key} failed: ${res.status} ${await safeText(res)}`);
|
|
45
|
+
}
|
|
46
|
+
/** Create an object exactly once. A concurrent winner returns false. */
|
|
47
|
+
async putIfAbsent(key, body, contentType = 'application/octet-stream') {
|
|
48
|
+
const res = await fetch(this.objUrl(key), {
|
|
49
|
+
method: 'PUT',
|
|
50
|
+
headers: this.authHeaders({ 'content-type': contentType, 'if-none-match': '*' }),
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
if (res.status === 409)
|
|
54
|
+
return false;
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
throw new Error(`sessions PUT-IF-ABSENT ${key} failed: ${res.status} ${await safeText(res)}`);
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
/** Fetch an object as text, or null if it does not exist (404). */
|
|
61
|
+
async get(key) {
|
|
62
|
+
const res = await fetch(this.objUrl(key), { method: 'GET', headers: this.authHeaders() });
|
|
63
|
+
if (res.status === 404)
|
|
64
|
+
return null;
|
|
65
|
+
if (!res.ok)
|
|
66
|
+
throw new Error(`sessions GET ${key} failed: ${res.status} ${await safeText(res)}`);
|
|
67
|
+
return await res.text();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* List this owner's object keys, each relative to the `<userId>/` prefix (so
|
|
71
|
+
* they feed straight back into `get`/`delete`). The `prefix` arg is accepted
|
|
72
|
+
* for interface parity with `R2Client` but ignored — the managed Worker
|
|
73
|
+
* enumerates the whole owner namespace and excludes the reserved `__` keys
|
|
74
|
+
* server-side.
|
|
75
|
+
*/
|
|
76
|
+
async list(_prefix) {
|
|
77
|
+
const url = `${this.base}/${encodeURIComponent(this.userId)}/?list`;
|
|
78
|
+
const res = await fetch(url, { method: 'GET', headers: this.authHeaders() });
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
throw new Error(`sessions LIST failed: ${res.status} ${await safeText(res)}`);
|
|
81
|
+
const body = (await res.json());
|
|
82
|
+
if (!body || !Array.isArray(body.keys))
|
|
83
|
+
return [];
|
|
84
|
+
return body.keys.filter((k) => typeof k === 'string');
|
|
85
|
+
}
|
|
86
|
+
/** Delete an object (the Worker refunds its bytes to the quota ledger). */
|
|
87
|
+
async delete(key) {
|
|
88
|
+
const res = await fetch(this.objUrl(key), { method: 'DELETE', headers: this.authHeaders() });
|
|
89
|
+
if (!res.ok && res.status !== 404) {
|
|
90
|
+
throw new Error(`sessions DELETE ${key} failed: ${res.status} ${await safeText(res)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function safeOwnerSegment(owner) {
|
|
95
|
+
const value = owner.trim();
|
|
96
|
+
if (!value || value === '.' || value === '..' || value.includes('/')) {
|
|
97
|
+
throw new Error(`Invalid managed sessions user id: ${JSON.stringify(owner)}`);
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
/** Prevent URL dot-segment normalization from escaping the verified owner path. */
|
|
102
|
+
function safeObjectKey(key) {
|
|
103
|
+
const segments = key.split('/');
|
|
104
|
+
if (segments.length === 0 ||
|
|
105
|
+
segments.some(segment => !segment || segment === '.' || segment === '..')) {
|
|
106
|
+
throw new Error(`Invalid managed sessions object key: ${JSON.stringify(key)}`);
|
|
107
|
+
}
|
|
108
|
+
return segments;
|
|
109
|
+
}
|
|
110
|
+
async function safeText(res) {
|
|
111
|
+
try {
|
|
112
|
+
return (await res.text()).slice(0, 200);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CloudflareRequester } from '../../share/provision.js';
|
|
2
|
+
interface ProvisionOptions {
|
|
3
|
+
request?: CloudflareRequester;
|
|
4
|
+
}
|
|
5
|
+
/** Bind the Phoenix identity base URL used to verify every session read and write. */
|
|
6
|
+
export declare function setPhoenixIdBaseSecret(apiToken: string, accountId: string, workerName: string, phoenixIdBase: string, opts?: ProvisionOptions): Promise<void>;
|
|
7
|
+
export interface ProvisionSessionsOptions extends ProvisionOptions {
|
|
8
|
+
apiToken: string;
|
|
9
|
+
accountId: string;
|
|
10
|
+
workerName?: string;
|
|
11
|
+
bucketName?: string;
|
|
12
|
+
phoenixIdBase: string;
|
|
13
|
+
domain?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Provision the complete isolated sessions deployment using the canonical Worker template. */
|
|
16
|
+
export declare function provisionSessions(opts: ProvisionSessionsOptions): Promise<{
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
}>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Cloudflare orchestration for the managed session-backup store. The generic
|
|
2
|
+
// request primitives live in share/provision; only the isolated sessions
|
|
3
|
+
// resource choices and Worker template belong here. Mirrors lib/traces/provision.ts.
|
|
4
|
+
//
|
|
5
|
+
// This is the OPERATOR provisioning path — cutting the `agents-sessions` Worker +
|
|
6
|
+
// bucket + custom domain once. Product traffic never runs it; a signed-in user
|
|
7
|
+
// talks to the already-deployed Worker through `SessionsHttpClient`.
|
|
8
|
+
import { addCustomDomain, createBucket, deployWorker, enableWorkersDev, findZoneId, putWorkerSecret, } from '../../share/provision.js';
|
|
9
|
+
import { DEFAULT_SESSIONS_BUCKET_NAME, DEFAULT_SESSIONS_DOMAIN, DEFAULT_SESSIONS_WORKER_NAME, } from './managed-config.js';
|
|
10
|
+
import { renderSessionsWorkerScript } from './worker-template.js';
|
|
11
|
+
/** Bind the Phoenix identity base URL used to verify every session read and write. */
|
|
12
|
+
export async function setPhoenixIdBaseSecret(apiToken, accountId, workerName, phoenixIdBase, opts = {}) {
|
|
13
|
+
const normalized = phoenixIdBase.replace(/\/+$/, '').trim();
|
|
14
|
+
if (!normalized)
|
|
15
|
+
throw new Error('PHOENIX_ID_BASE is required to verify session requests.');
|
|
16
|
+
await putWorkerSecret(apiToken, accountId, workerName, 'PHOENIX_ID_BASE', normalized, opts);
|
|
17
|
+
}
|
|
18
|
+
/** Provision the complete isolated sessions deployment using the canonical Worker template. */
|
|
19
|
+
export async function provisionSessions(opts) {
|
|
20
|
+
const domain = opts.domain ?? DEFAULT_SESSIONS_DOMAIN;
|
|
21
|
+
const workerName = opts.workerName ?? DEFAULT_SESSIONS_WORKER_NAME;
|
|
22
|
+
const bucketName = opts.bucketName ?? DEFAULT_SESSIONS_BUCKET_NAME;
|
|
23
|
+
const requestOpts = opts.request ? { request: opts.request } : {};
|
|
24
|
+
await createBucket(opts.apiToken, opts.accountId, bucketName, requestOpts);
|
|
25
|
+
await deployWorker(opts.apiToken, opts.accountId, workerName, renderSessionsWorkerScript(), bucketName, requestOpts);
|
|
26
|
+
// No WRITE_TOKEN secret: the managed sessions Worker is Phoenix-only (PHNX-3726).
|
|
27
|
+
// A static token would be a Phoenix-and-quota bypass with no legitimate caller —
|
|
28
|
+
// the zero-knowledge BYO path uses the user's own R2 bucket directly, never this
|
|
29
|
+
// Worker.
|
|
30
|
+
await setPhoenixIdBaseSecret(opts.apiToken, opts.accountId, workerName, opts.phoenixIdBase, requestOpts);
|
|
31
|
+
const workersDevSubdomain = await enableWorkersDev(opts.apiToken, opts.accountId, workerName, requestOpts);
|
|
32
|
+
const zoneId = await findZoneId(opts.apiToken, domain, requestOpts);
|
|
33
|
+
if (!zoneId) {
|
|
34
|
+
return { baseUrl: `https://${workerName}.${workersDevSubdomain}.workers.dev` };
|
|
35
|
+
}
|
|
36
|
+
await addCustomDomain(opts.apiToken, opts.accountId, workerName, zoneId, domain, requestOpts);
|
|
37
|
+
return { baseUrl: `https://${domain}` };
|
|
38
|
+
}
|
|
@@ -15,6 +15,7 @@ export interface HeadResult {
|
|
|
15
15
|
etag: string;
|
|
16
16
|
}
|
|
17
17
|
export declare class R2Client {
|
|
18
|
+
readonly kind: "byo";
|
|
18
19
|
private aws;
|
|
19
20
|
private base;
|
|
20
21
|
constructor(cfg: R2Config);
|
|
@@ -29,6 +30,8 @@ export declare class R2Client {
|
|
|
29
30
|
delete(key: string): Promise<void>;
|
|
30
31
|
/** List immediate sub-prefixes under a prefix (delimiter '/'), e.g. machine dirs. */
|
|
31
32
|
listPrefixes(prefix: string): Promise<string[]>;
|
|
32
|
-
/** List all object keys under a prefix (handles pagination).
|
|
33
|
-
|
|
33
|
+
/** List all object keys under a prefix (handles pagination). An empty prefix
|
|
34
|
+
* lists the whole bucket — the default so R2Client satisfies the shared
|
|
35
|
+
* {@link SessionsBackupClient} interface (managed lists with no prefix). */
|
|
36
|
+
list(prefix?: string): Promise<string[]>;
|
|
34
37
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { AwsClient } from 'aws4fetch';
|
|
13
13
|
export class R2Client {
|
|
14
|
+
kind = 'byo';
|
|
14
15
|
aws;
|
|
15
16
|
base;
|
|
16
17
|
constructor(cfg) {
|
|
@@ -84,8 +85,10 @@ export class R2Client {
|
|
|
84
85
|
} while (token);
|
|
85
86
|
return prefixes;
|
|
86
87
|
}
|
|
87
|
-
/** List all object keys under a prefix (handles pagination).
|
|
88
|
-
|
|
88
|
+
/** List all object keys under a prefix (handles pagination). An empty prefix
|
|
89
|
+
* lists the whole bucket — the default so R2Client satisfies the shared
|
|
90
|
+
* {@link SessionsBackupClient} interface (managed lists with no prefix). */
|
|
91
|
+
async list(prefix = '') {
|
|
89
92
|
const keys = [];
|
|
90
93
|
let token;
|
|
91
94
|
do {
|