@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,847 @@
|
|
|
1
|
+
// The Cloudflare Worker that fronts the R2 managed session-backup bucket.
|
|
2
|
+
//
|
|
3
|
+
// SECURITY: like agents-traces (and UNLIKE agents-share), there is NO public GET
|
|
4
|
+
// path here. Every route — PUT, GET, LIST, DELETE — requires a Phoenix bearer;
|
|
5
|
+
// there is NO static-token principal (the zero-knowledge BYO path talks to the
|
|
6
|
+
// user's own R2 bucket directly and never reaches this Worker, so a WRITE_TOKEN
|
|
7
|
+
// here would only be a Phoenix-and-quota bypass — PHNX-3726). The caller must be
|
|
8
|
+
// the record's owner: the path's first segment MUST equal the verified userId. A
|
|
9
|
+
// missing bearer → 401; a wrong owner → 403; public cache headers are NEVER
|
|
10
|
+
// emitted. A stored transcript body MUST be an AES-256-GCM envelope — a plaintext
|
|
11
|
+
// PUT is rejected 422 so readable transcript content can never land in the bucket.
|
|
12
|
+
//
|
|
13
|
+
// Object layout is OPAQUE to the Worker: the CLI stores each session under
|
|
14
|
+
// <userId>/sessions/<machine>/<agent>/<sessionId>.jsonl (file-shaped)
|
|
15
|
+
// <userId>/sessions/<machine>/<agent>/<sessionId>/<relKey> (dir-shaped)
|
|
16
|
+
// but the Worker only enforces segments[0] === userId and never parses the rest.
|
|
17
|
+
// Deliberately DIFFERENT from traces: there is NO `/all` cross-device merge here.
|
|
18
|
+
// Session transcripts are opaque, client-side-encrypted envelopes — merging or
|
|
19
|
+
// re-projecting them server-side would corrupt them — so the Worker is a pure
|
|
20
|
+
// per-owner blob store with a server-side LIST for restore.
|
|
21
|
+
//
|
|
22
|
+
// Reserved prefixes `__usage` (the CAS quota ledger) and `__key` (the escrowed
|
|
23
|
+
// backup DEK) are owner-bearer only and are NEVER returned by LIST.
|
|
24
|
+
//
|
|
25
|
+
// Routes (all require a Phoenix bearer + segments[0] === userId):
|
|
26
|
+
// PUT /<userId>/<key...> — store an object; quota-charged (413 over quota)
|
|
27
|
+
// GET /<userId>/<key...> — return the stored object (404 if absent)
|
|
28
|
+
// GET /<userId>/?list — JSON array of this owner's keys (no __ prefixes),
|
|
29
|
+
// each relative to <userId>/ (the CLI re-prepends)
|
|
30
|
+
// DELETE /<userId>/<key...> — logical delete via hidden tombstone; refunds quota
|
|
31
|
+
// GET / — 401 (there is no public route)
|
|
32
|
+
//
|
|
33
|
+
// Emitted as a string so it compiles into dist/** with no package.json#files
|
|
34
|
+
// change. `provision.ts` uploads this verbatim as an ES-module Worker with a
|
|
35
|
+
// BUCKET (R2) binding + a PHOENIX_ID_BASE secret.
|
|
36
|
+
/**
|
|
37
|
+
* Render the managed sessions Worker source. Pure — bindings are wired at
|
|
38
|
+
* deploy time. No `${}` interpolation in the emitted source (string
|
|
39
|
+
* concatenation only) so the outer template literal never captures it.
|
|
40
|
+
*/
|
|
41
|
+
export function renderSessionsWorkerScript() {
|
|
42
|
+
return `// GENERATED by agents-cli agents sessions — do not edit; edit
|
|
43
|
+
// src/lib/session/sync/worker-template.ts and re-provision.
|
|
44
|
+
export const hooks = {
|
|
45
|
+
verifyPhoenixToken: defaultVerifyPhoenixToken,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Free-tier limits for one owner's backup namespace. Product behavior lives in
|
|
49
|
+
// code; secrets are bindings, quotas are not ambient deployment knobs.
|
|
50
|
+
const PLAN = {
|
|
51
|
+
maxBytes: 5 * 1024 * 1024 * 1024,
|
|
52
|
+
maxObjects: 200000,
|
|
53
|
+
maxPaths: 200000,
|
|
54
|
+
maxFileBytes: 64 * 1024 * 1024,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export default {
|
|
58
|
+
async fetch(request, env) {
|
|
59
|
+
const url = new URL(request.url);
|
|
60
|
+
let path;
|
|
61
|
+
try {
|
|
62
|
+
path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
|
|
63
|
+
} catch {
|
|
64
|
+
return json({ error: 'malformed object path' }, 400);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!path) {
|
|
68
|
+
return json({ error: 'unauthorized' }, 401);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (request.method === 'PUT') {
|
|
72
|
+
return handlePut(request, env, path);
|
|
73
|
+
}
|
|
74
|
+
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
75
|
+
return handleGet(request, env, path, url);
|
|
76
|
+
}
|
|
77
|
+
if (request.method === 'DELETE') {
|
|
78
|
+
return handleDelete(request, env, path);
|
|
79
|
+
}
|
|
80
|
+
return json({ error: 'method not allowed' }, 405);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// The reserved first-key-segment prefixes: owner-bearer only, never listed, and
|
|
85
|
+
// never quota-charged/refunded (the ledger and the DEK are infrastructure, not
|
|
86
|
+
// user payload).
|
|
87
|
+
function isReservedRelKey(rel) {
|
|
88
|
+
const first = rel.split('/').filter(Boolean)[0] || '';
|
|
89
|
+
return first === '__usage' || first === '__key';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handlePut(request, env, path) {
|
|
93
|
+
const auth = await authorizeWrite(request, env);
|
|
94
|
+
if (auth.error) return auth.error;
|
|
95
|
+
|
|
96
|
+
const segments = path.split('/').filter(Boolean);
|
|
97
|
+
const owner = auth.owner;
|
|
98
|
+
if (!owner || segments[0] !== owner) {
|
|
99
|
+
return json({ error: 'namespace mismatch', owner: owner }, 403);
|
|
100
|
+
}
|
|
101
|
+
const rel = segments.slice(1).join('/');
|
|
102
|
+
if (!rel) return json({ error: 'missing object key' }, 400);
|
|
103
|
+
|
|
104
|
+
const reserved = isReservedRelKey(rel);
|
|
105
|
+
const isEscrow = rel === '__key/backup-dek';
|
|
106
|
+
if (reserved && !isEscrow) {
|
|
107
|
+
return json({ error: 'reserved object key' }, 403);
|
|
108
|
+
}
|
|
109
|
+
const contentType = request.headers.get('content-type') || 'application/octet-stream';
|
|
110
|
+
|
|
111
|
+
// Read the body bounded to the per-file cap so a streaming body can never OOM
|
|
112
|
+
// the Worker or slip past the quota by lying about its length.
|
|
113
|
+
const read = await readBodyBounded(request, PLAN.maxFileBytes);
|
|
114
|
+
if (read.oversize) {
|
|
115
|
+
return json({ error: 'file too large', maxBytes: PLAN.maxFileBytes }, 413);
|
|
116
|
+
}
|
|
117
|
+
const body = read.bytes;
|
|
118
|
+
|
|
119
|
+
// The escrow key is immutable: all first-use devices conditionally create it,
|
|
120
|
+
// then losers read the one winner before encrypting a transcript.
|
|
121
|
+
if (isEscrow) {
|
|
122
|
+
const created = await env.BUCKET.put(path, body, {
|
|
123
|
+
httpMetadata: { contentType: contentType },
|
|
124
|
+
customMetadata: { owner: owner, uploadedAt: new Date().toISOString() },
|
|
125
|
+
onlyIf: { etagDoesNotMatch: '*' },
|
|
126
|
+
});
|
|
127
|
+
if (created === null) return json({ error: 'already exists', key: path }, 409);
|
|
128
|
+
return json({ ok: true, key: path }, 200);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fail closed on a plaintext payload: a managed transcript bundle is always
|
|
132
|
+
// client-side-encrypted (SES-51), so an unencrypted bundle here is an
|
|
133
|
+
// older/buggy client and MUST be rejected before it lands readable.
|
|
134
|
+
if (!isEncryptedManagedBundle(body)) {
|
|
135
|
+
return json({ error: 'payload must be an encrypted bundle' }, 422);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Reserve a bounded historical path slot BEFORE creating its persistent lease
|
|
139
|
+
// or tombstone. Live-object count is refundable; internal state is not safely
|
|
140
|
+
// garbage-collectable while an unbounded-duration predecessor may resume, so
|
|
141
|
+
// this separate cap prevents create/delete churn from growing R2 forever.
|
|
142
|
+
const pathId = await mutationPathId(path);
|
|
143
|
+
const pathReservation = await reserveMutationPath(env, owner, pathId, PLAN);
|
|
144
|
+
if (pathReservation instanceof Response) return pathReservation;
|
|
145
|
+
|
|
146
|
+
const acquired = await acquireMutationLease(env, owner, path);
|
|
147
|
+
if (acquired.error) return acquired.error;
|
|
148
|
+
const lease = acquired.lease;
|
|
149
|
+
try {
|
|
150
|
+
// PUT and DELETE share this per-key lease. The operation record is durable
|
|
151
|
+
// BEFORE its usage delta, so an expired successor can reconcile an
|
|
152
|
+
// interrupted request from the actual object's mutationToken metadata.
|
|
153
|
+
const physicalPrior = await env.BUCKET.head(path);
|
|
154
|
+
const prior = physicalPrior && !isDeletedObject(physicalPrior) ? physicalPrior : null;
|
|
155
|
+
const priorBytes = prior && typeof prior.size === 'number' ? prior.size : 0;
|
|
156
|
+
const operation = {
|
|
157
|
+
token: lease.token,
|
|
158
|
+
kind: 'put',
|
|
159
|
+
path: path,
|
|
160
|
+
pathId: lease.pathId,
|
|
161
|
+
generation: lease.generation,
|
|
162
|
+
priorEtag: physicalPrior && physicalPrior.etag ? physicalPrior.etag : null,
|
|
163
|
+
};
|
|
164
|
+
if (!await setLeaseOperation(env, lease, operation)) {
|
|
165
|
+
return json({ error: 'mutation lease contended, retry' }, 503);
|
|
166
|
+
}
|
|
167
|
+
const charge = await beginUsageMutation(env, owner, {
|
|
168
|
+
operation: operation,
|
|
169
|
+
fileBytes: body.byteLength,
|
|
170
|
+
bytes: body.byteLength - priorBytes,
|
|
171
|
+
count: prior ? 0 : 1,
|
|
172
|
+
}, PLAN);
|
|
173
|
+
if (charge instanceof Response) {
|
|
174
|
+
await setLeaseOperation(env, lease, null);
|
|
175
|
+
return charge;
|
|
176
|
+
}
|
|
177
|
+
// Renew/fence immediately before touching the object. A successor that
|
|
178
|
+
// reclaimed an expired holder prevents this request from mutating state.
|
|
179
|
+
if (!await setLeaseOperation(env, lease, operation)) {
|
|
180
|
+
return json({ error: 'mutation lease lost; retry' }, 409);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const putOpts = {
|
|
184
|
+
httpMetadata: { contentType: contentType },
|
|
185
|
+
customMetadata: { owner: owner, uploadedAt: new Date().toISOString(), mutationToken: lease.token },
|
|
186
|
+
onlyIf: physicalPrior && physicalPrior.etag
|
|
187
|
+
? { etagMatches: physicalPrior.etag }
|
|
188
|
+
: { etagDoesNotMatch: '*' },
|
|
189
|
+
};
|
|
190
|
+
let stored;
|
|
191
|
+
try {
|
|
192
|
+
stored = await env.BUCKET.put(path, body, putOpts);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const reconciled = await reconcileUsageMutation(env, owner, operation);
|
|
195
|
+
if (reconciled) await setLeaseOperation(env, lease, null);
|
|
196
|
+
if (!reconciled) return json({ error: 'usage reconciliation contended, retry' }, 503);
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
if (stored === null) {
|
|
200
|
+
const rolledBack = await finishUsageMutation(env, owner, operation, true);
|
|
201
|
+
if (rolledBack) await setLeaseOperation(env, lease, null);
|
|
202
|
+
if (!rolledBack) return json({ error: 'usage reconciliation contended, retry' }, 503);
|
|
203
|
+
return json({ error: 'conflict', key: path }, 409);
|
|
204
|
+
}
|
|
205
|
+
const committed = await finishUsageMutation(env, owner, operation, false);
|
|
206
|
+
if (committed) await setLeaseOperation(env, lease, null);
|
|
207
|
+
if (!committed) return json({ error: 'usage reconciliation contended, retry' }, 503);
|
|
208
|
+
} finally {
|
|
209
|
+
await releaseMutationLease(env, lease);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const headers = new Headers();
|
|
213
|
+
headers.set('content-type', 'application/json; charset=utf-8');
|
|
214
|
+
headers.set('cache-control', 'private, no-store');
|
|
215
|
+
return new Response(JSON.stringify({ ok: true, key: path }), { status: 200, headers });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleGet(request, env, path, url) {
|
|
219
|
+
const auth = await authorizeRead(request, env, path);
|
|
220
|
+
if (auth.error) return auth.error;
|
|
221
|
+
|
|
222
|
+
const segments = path.split('/').filter(Boolean);
|
|
223
|
+
const owner = segments[0];
|
|
224
|
+
|
|
225
|
+
// GET /<userId>/?list — enumerate this owner's keys (never the __ reserved
|
|
226
|
+
// prefixes), each relative to <userId>/ so the CLI re-prepends inside its
|
|
227
|
+
// client (BYO object layout stays identical).
|
|
228
|
+
if (segments.length === 1 && url.searchParams.has('list')) {
|
|
229
|
+
const keys = await listOwnerKeys(env, owner);
|
|
230
|
+
const headers = new Headers();
|
|
231
|
+
headers.set('content-type', 'application/json; charset=utf-8');
|
|
232
|
+
headers.set('cache-control', 'private, no-store');
|
|
233
|
+
return new Response(JSON.stringify({ keys: keys }), { status: 200, headers });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const object = await env.BUCKET.get(path);
|
|
237
|
+
if (!object || isDeletedObject(object)) return json({ error: 'not found' }, 404);
|
|
238
|
+
|
|
239
|
+
const headers = new Headers(object.httpMetadata ?? {});
|
|
240
|
+
headers.set('cache-control', 'private, no-store');
|
|
241
|
+
return new Response(request.method === 'HEAD' ? null : object.body, { headers });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function handleDelete(request, env, path) {
|
|
245
|
+
const auth = await authorizeWrite(request, env);
|
|
246
|
+
if (auth.error) return auth.error;
|
|
247
|
+
|
|
248
|
+
const segments = path.split('/').filter(Boolean);
|
|
249
|
+
const owner = auth.owner;
|
|
250
|
+
if (!owner || segments[0] !== owner) {
|
|
251
|
+
return json({ error: 'namespace mismatch', owner: owner }, 403);
|
|
252
|
+
}
|
|
253
|
+
const rel = segments.slice(1).join('/');
|
|
254
|
+
if (!rel) return json({ error: 'missing object key' }, 400);
|
|
255
|
+
|
|
256
|
+
const reserved = isReservedRelKey(rel);
|
|
257
|
+
if (reserved) {
|
|
258
|
+
// Reserved infrastructure records are not user-deletable: deleting the
|
|
259
|
+
// immutable escrow would orphan ciphertext on the next fresh-device mint.
|
|
260
|
+
return json({ error: 'reserved object key' }, 403);
|
|
261
|
+
}
|
|
262
|
+
const beforeLease = await env.BUCKET.head(path);
|
|
263
|
+
if (!beforeLease) return json({ ok: true, key: path }, 200);
|
|
264
|
+
const pathId = await mutationPathId(path);
|
|
265
|
+
const pathReservation = await reserveMutationPath(env, owner, pathId, PLAN);
|
|
266
|
+
if (pathReservation instanceof Response) return pathReservation;
|
|
267
|
+
if (isDeletedObject(beforeLease)) return json({ ok: true, key: path }, 200);
|
|
268
|
+
|
|
269
|
+
const acquired = await acquireMutationLease(env, owner, path);
|
|
270
|
+
if (acquired.error) return acquired.error;
|
|
271
|
+
const lease = acquired.lease;
|
|
272
|
+
try {
|
|
273
|
+
const existing = await env.BUCKET.head(path);
|
|
274
|
+
if (existing && !isDeletedObject(existing)) {
|
|
275
|
+
const size = typeof existing.size === 'number' ? existing.size : 0;
|
|
276
|
+
const operation = {
|
|
277
|
+
token: lease.token,
|
|
278
|
+
kind: 'delete',
|
|
279
|
+
path: path,
|
|
280
|
+
pathId: lease.pathId,
|
|
281
|
+
generation: lease.generation,
|
|
282
|
+
priorEtag: existing.etag || null,
|
|
283
|
+
};
|
|
284
|
+
if (!await setLeaseOperation(env, lease, operation)) {
|
|
285
|
+
return json({ error: 'mutation lease contended, retry' }, 503);
|
|
286
|
+
}
|
|
287
|
+
const refund = await beginUsageMutation(env, owner, {
|
|
288
|
+
operation: operation, bytes: -size, count: -1,
|
|
289
|
+
}, PLAN);
|
|
290
|
+
if (refund instanceof Response) {
|
|
291
|
+
await setLeaseOperation(env, lease, null);
|
|
292
|
+
return refund;
|
|
293
|
+
}
|
|
294
|
+
if (!await setLeaseOperation(env, lease, operation)) {
|
|
295
|
+
return json({ error: 'mutation lease lost; retry' }, 409);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const deleted = await env.BUCKET.put(path, new Uint8Array(0), {
|
|
299
|
+
httpMetadata: { contentType: 'application/x-agents-session-tombstone' },
|
|
300
|
+
customMetadata: {
|
|
301
|
+
owner: owner,
|
|
302
|
+
deleted: 'true',
|
|
303
|
+
mutationToken: lease.token,
|
|
304
|
+
deletedAt: new Date().toISOString(),
|
|
305
|
+
},
|
|
306
|
+
onlyIf: { etagMatches: existing.etag },
|
|
307
|
+
});
|
|
308
|
+
if (deleted === null) {
|
|
309
|
+
const reconciled = await reconcileUsageMutation(env, owner, operation);
|
|
310
|
+
if (reconciled) await setLeaseOperation(env, lease, null);
|
|
311
|
+
if (!reconciled) return json({ error: 'usage reconciliation contended, retry' }, 503);
|
|
312
|
+
return json({ error: 'conflict', key: path }, 409);
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const reconciled = await reconcileUsageMutation(env, owner, operation);
|
|
316
|
+
if (reconciled) await setLeaseOperation(env, lease, null);
|
|
317
|
+
if (!reconciled) return json({ error: 'usage reconciliation contended, retry' }, 503);
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
const committed = await finishUsageMutation(env, owner, operation, false);
|
|
321
|
+
if (committed) await setLeaseOperation(env, lease, null);
|
|
322
|
+
if (!committed) return json({ error: 'usage reconciliation contended, retry' }, 503);
|
|
323
|
+
}
|
|
324
|
+
} finally {
|
|
325
|
+
await releaseMutationLease(env, lease);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const headers = new Headers();
|
|
329
|
+
headers.set('content-type', 'application/json; charset=utf-8');
|
|
330
|
+
headers.set('cache-control', 'private, no-store');
|
|
331
|
+
return new Response(JSON.stringify({ ok: true, key: path }), { status: 200, headers });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Recoverable, storage-backed mutex shared by managed PUT and DELETE for one
|
|
335
|
+
// object path. A released lease remains as a tiny internal record so release is
|
|
336
|
+
// an etag-guarded PUT (never an unsafe unconditional delete). An expired holder
|
|
337
|
+
// can be replaced, and its durable operation is reconciled before new work.
|
|
338
|
+
const MUTATION_LEASE_MS = 60 * 1000;
|
|
339
|
+
|
|
340
|
+
async function mutationPathId(path) {
|
|
341
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(path));
|
|
342
|
+
return Array.from(new Uint8Array(digest)).map(function (b) { return b.toString(16).padStart(2, '0'); }).join('');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function mutationLeaseKey(owner, path) {
|
|
346
|
+
return '__mutation/' + owner + '/' + await mutationPathId(path);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function readMutationLease(env, key) {
|
|
350
|
+
const obj = await env.BUCKET.get(key);
|
|
351
|
+
if (!obj) return null;
|
|
352
|
+
let record = null;
|
|
353
|
+
try {
|
|
354
|
+
const parsed = JSON.parse(await obj.text());
|
|
355
|
+
if (parsed && (parsed.v === 1 || parsed.v === 2) && typeof parsed.holder === 'string') record = parsed;
|
|
356
|
+
} catch { /* malformed internal record is reclaimable */ }
|
|
357
|
+
return { etag: obj.etag || null, record: record };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function writeMutationLeaseCas(env, key, priorEtag, record) {
|
|
361
|
+
const opts = { httpMetadata: { contentType: 'application/json' } };
|
|
362
|
+
opts.onlyIf = priorEtag ? { etagMatches: priorEtag } : { etagDoesNotMatch: '*' };
|
|
363
|
+
const stored = await env.BUCKET.put(key, JSON.stringify(record), opts);
|
|
364
|
+
return stored !== null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function acquireMutationLease(env, owner, path) {
|
|
368
|
+
const key = await mutationLeaseKey(owner, path);
|
|
369
|
+
const pathId = key.slice(key.lastIndexOf('/') + 1);
|
|
370
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
371
|
+
const state = await readMutationLease(env, key);
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
if (state && state.record && state.record.expiresAt > now) {
|
|
374
|
+
return { error: json({ error: 'object mutation already in progress; retry' }, 409) };
|
|
375
|
+
}
|
|
376
|
+
const token = crypto.randomUUID();
|
|
377
|
+
const generation = state && state.record && Number.isFinite(state.record.generation)
|
|
378
|
+
? state.record.generation + 1
|
|
379
|
+
: 1;
|
|
380
|
+
// Preserve an expired holder's operation in the replacement record. If this
|
|
381
|
+
// Worker dies during recovery, the next successor still sees the same work.
|
|
382
|
+
const record = {
|
|
383
|
+
v: 2,
|
|
384
|
+
generation: generation,
|
|
385
|
+
holder: token,
|
|
386
|
+
expiresAt: now + MUTATION_LEASE_MS,
|
|
387
|
+
operation: state && state.record ? state.record.operation || null : null,
|
|
388
|
+
};
|
|
389
|
+
if (!await writeMutationLeaseCas(env, key, state && state.etag, record)) continue;
|
|
390
|
+
const lease = { key: key, token: token, pathId: pathId, generation: generation };
|
|
391
|
+
if (record.operation) {
|
|
392
|
+
// Before settling an expired predecessor, invalidate the exact object etag
|
|
393
|
+
// it was authorized to mutate. A predecessor that later resumes can no
|
|
394
|
+
// longer PUT or DELETE the successor's object.
|
|
395
|
+
const recovered = await fenceAndReconcileStaleMutation(env, owner, record.operation, token);
|
|
396
|
+
if (!recovered || !await setLeaseOperation(env, lease, null)) {
|
|
397
|
+
await releaseMutationLease(env, lease);
|
|
398
|
+
return { error: json({ error: 'stale mutation reconciliation contended, retry' }, 503) };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { lease: lease };
|
|
402
|
+
}
|
|
403
|
+
return { error: json({ error: 'mutation lease contended, retry' }, 503) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function setLeaseOperation(env, lease, operation) {
|
|
407
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
408
|
+
const state = await readMutationLease(env, lease.key);
|
|
409
|
+
if (!state || !state.record || state.record.holder !== lease.token) return false;
|
|
410
|
+
const next = {
|
|
411
|
+
v: 2,
|
|
412
|
+
generation: lease.generation,
|
|
413
|
+
holder: lease.token,
|
|
414
|
+
expiresAt: Date.now() + MUTATION_LEASE_MS,
|
|
415
|
+
operation: operation,
|
|
416
|
+
};
|
|
417
|
+
if (await writeMutationLeaseCas(env, lease.key, state.etag, next)) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function releaseMutationLease(env, lease) {
|
|
423
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
424
|
+
const state = await readMutationLease(env, lease.key);
|
|
425
|
+
if (!state || !state.record || state.record.holder !== lease.token) return false;
|
|
426
|
+
// Keep any unsettled operation recoverable, but make the lease immediately
|
|
427
|
+
// acquirable. A normal completed request cleared operation first.
|
|
428
|
+
const next = {
|
|
429
|
+
v: 2,
|
|
430
|
+
generation: lease.generation,
|
|
431
|
+
holder: '',
|
|
432
|
+
expiresAt: 0,
|
|
433
|
+
operation: state.record.operation || null,
|
|
434
|
+
};
|
|
435
|
+
if (await writeMutationLeaseCas(env, lease.key, state.etag, next)) return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function reconcileUsageMutation(env, owner, operation) {
|
|
441
|
+
if (!validMutationOperation(operation)) return true;
|
|
442
|
+
const current = await env.BUCKET.head(operation.path);
|
|
443
|
+
return finishUsageMutation(env, owner, operation, !mutationApplied(current, operation));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function validMutationOperation(operation) {
|
|
447
|
+
return !!operation
|
|
448
|
+
&& typeof operation.token === 'string'
|
|
449
|
+
&& typeof operation.path === 'string'
|
|
450
|
+
&& typeof operation.pathId === 'string'
|
|
451
|
+
&& Number.isFinite(operation.generation);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function mutationApplied(object, operation) {
|
|
455
|
+
const tokenMatches = !!object && !!object.customMetadata
|
|
456
|
+
&& object.customMetadata.mutationToken === operation.token;
|
|
457
|
+
return operation.kind === 'delete'
|
|
458
|
+
? tokenMatches && isDeletedObject(object)
|
|
459
|
+
: tokenMatches && !isDeletedObject(object);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Reclaim an expired predecessor safely. Its object mutation was conditional on
|
|
463
|
+
// priorEtag, so we first change/create that physical object under the same etag
|
|
464
|
+
// condition. Exactly one side wins: if the predecessor already won, its
|
|
465
|
+
// mutationToken is visible and its quota delta commits; if this fence wins, the
|
|
466
|
+
// predecessor's later conditional mutation must fail and its delta rolls back.
|
|
467
|
+
async function fenceAndReconcileStaleMutation(env, owner, operation, successorToken) {
|
|
468
|
+
if (!validMutationOperation(operation)) return true;
|
|
469
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
470
|
+
const current = await env.BUCKET.get(operation.path);
|
|
471
|
+
if (mutationApplied(current, operation)) {
|
|
472
|
+
return finishUsageMutation(env, owner, operation, false);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const priorEtag = typeof operation.priorEtag === 'string' ? operation.priorEtag : null;
|
|
476
|
+
if (priorEtag && current && current.etag === priorEtag) {
|
|
477
|
+
const bytes = new Uint8Array(await current.arrayBuffer());
|
|
478
|
+
const metadata = Object.assign({}, current.customMetadata || {}, { fencedBy: successorToken });
|
|
479
|
+
const fenced = await env.BUCKET.put(operation.path, bytes, {
|
|
480
|
+
httpMetadata: current.httpMetadata || {},
|
|
481
|
+
customMetadata: metadata,
|
|
482
|
+
onlyIf: { etagMatches: current.etag },
|
|
483
|
+
});
|
|
484
|
+
if (fenced === null) continue;
|
|
485
|
+
} else if (!priorEtag && !current) {
|
|
486
|
+
const fenced = await env.BUCKET.put(operation.path, new Uint8Array(0), {
|
|
487
|
+
httpMetadata: { contentType: 'application/x-agents-session-tombstone' },
|
|
488
|
+
customMetadata: { owner: owner, deleted: 'true', fencedBy: successorToken },
|
|
489
|
+
onlyIf: { etagDoesNotMatch: '*' },
|
|
490
|
+
});
|
|
491
|
+
if (fenced === null) continue;
|
|
492
|
+
}
|
|
493
|
+
// The prior etag is now absent, changed, or fenced by us. A late predecessor
|
|
494
|
+
// cannot pass its object conditional. Persist its terminal generation in the
|
|
495
|
+
// SAME CAS ledger that stores pending deltas before rolling back.
|
|
496
|
+
return finishUsageMutation(env, owner, operation, true);
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Enumerate every stored key under <owner>/, EXCLUDING the reserved __ prefixes,
|
|
502
|
+
// returning each stripped of the <owner>/ prefix. Paginates R2's cursor so a
|
|
503
|
+
// large backup set lists completely.
|
|
504
|
+
async function listOwnerKeys(env, owner) {
|
|
505
|
+
const base = owner + '/';
|
|
506
|
+
const out = [];
|
|
507
|
+
let cursor = undefined;
|
|
508
|
+
do {
|
|
509
|
+
const listOpts = cursor
|
|
510
|
+
? { prefix: base, cursor: cursor, include: ['customMetadata'] }
|
|
511
|
+
: { prefix: base, include: ['customMetadata'] };
|
|
512
|
+
const listed = await env.BUCKET.list(listOpts);
|
|
513
|
+
const objects = (listed && listed.objects) || [];
|
|
514
|
+
for (const o of objects) {
|
|
515
|
+
const key = o.key || '';
|
|
516
|
+
if (!key.startsWith(base)) continue;
|
|
517
|
+
const rel = key.slice(base.length);
|
|
518
|
+
if (!rel || isReservedRelKey(rel) || isDeletedObject(o)) continue;
|
|
519
|
+
out.push(rel);
|
|
520
|
+
}
|
|
521
|
+
cursor = listed && listed.truncated ? listed.cursor : undefined;
|
|
522
|
+
} while (cursor);
|
|
523
|
+
return out.sort();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isDeletedObject(object) {
|
|
527
|
+
return !!object && !!object.customMetadata && object.customMetadata.deleted === 'true';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --- Per-user usage ledger (R2 conditional-put CAS), mirrored from agents-share.
|
|
531
|
+
// A single object at __usage/<owner> holds
|
|
532
|
+
// { bytes, count, pathCount, pending, settled } for one user. R2 has no atomic
|
|
533
|
+
// increment, so mutations use read → mutate →
|
|
534
|
+
// conditional-put (onlyIf.etagMatches). The __usage key is reserved:
|
|
535
|
+
// owner-bearer only, never listed.
|
|
536
|
+
function usageKey(owner) { return '__usage/' + owner; }
|
|
537
|
+
|
|
538
|
+
function usageNumber(v, dflt) {
|
|
539
|
+
const n = typeof v === 'number' ? v : parseFloat(v);
|
|
540
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function readUsage(env, owner) {
|
|
544
|
+
const obj = await env.BUCKET.get(usageKey(owner));
|
|
545
|
+
if (!obj) return { etag: null, usage: { bytes: 0, count: 0, pathCount: 0, pending: {}, settled: {} } };
|
|
546
|
+
let usage = { bytes: 0, count: 0, pathCount: 0, pending: {}, settled: {} };
|
|
547
|
+
try {
|
|
548
|
+
const raw = typeof obj.text === 'function' ? await obj.text() : '';
|
|
549
|
+
const parsed = JSON.parse(raw);
|
|
550
|
+
if (parsed && typeof parsed === 'object') {
|
|
551
|
+
const settled = parsed.settled && typeof parsed.settled === 'object' ? parsed.settled : {};
|
|
552
|
+
usage = {
|
|
553
|
+
bytes: usageNumber(parsed.bytes, 0),
|
|
554
|
+
count: usageNumber(parsed.count, 0),
|
|
555
|
+
pathCount: usageNumber(parsed.pathCount, Object.keys(settled).length),
|
|
556
|
+
pending: parsed.pending && typeof parsed.pending === 'object' ? parsed.pending : {},
|
|
557
|
+
settled: settled,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
} catch (e) {
|
|
561
|
+
// Malformed ledger — treat as fresh zero but KEEP the etag so the next CAS
|
|
562
|
+
// put overwrites the corrupt object rather than looping against it forever.
|
|
563
|
+
}
|
|
564
|
+
return { etag: obj.etag || null, usage: usage };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function writeUsageCas(env, owner, prevEtag, usage) {
|
|
568
|
+
if (usage.pending && Object.keys(usage.pending).length === 0) delete usage.pending;
|
|
569
|
+
const body = JSON.stringify(usage);
|
|
570
|
+
const opts = { httpMetadata: { contentType: 'application/json' } };
|
|
571
|
+
if (prevEtag) {
|
|
572
|
+
opts.onlyIf = { etagMatches: prevEtag };
|
|
573
|
+
const res = await env.BUCKET.put(usageKey(owner), body, opts);
|
|
574
|
+
return res !== null;
|
|
575
|
+
}
|
|
576
|
+
opts.onlyIf = { etagDoesNotMatch: '*' };
|
|
577
|
+
const res = await env.BUCKET.put(usageKey(owner), body, opts);
|
|
578
|
+
return res !== null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// CAS retry loop. fn(usage) returns { reject: Response } to fail loud without
|
|
582
|
+
// committing, or { commit: usage } to persist. On CAS contention it re-reads and
|
|
583
|
+
// retries; exhausting the retries fails loud with 503.
|
|
584
|
+
async function withUsage(env, owner, fn) {
|
|
585
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
586
|
+
const state = await readUsage(env, owner);
|
|
587
|
+
const outcome = fn(state.usage);
|
|
588
|
+
if (outcome.reject) return outcome.reject;
|
|
589
|
+
if (outcome.skip) return null;
|
|
590
|
+
const ok = await writeUsageCas(env, owner, state.etag, outcome.commit);
|
|
591
|
+
if (ok) return null;
|
|
592
|
+
}
|
|
593
|
+
return json({ error: 'usage ledger contended, retry' }, 503);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Permanently reserve one logical object-path hash in the quota ledger before
|
|
597
|
+
// any persistent per-path state is created. DELETE refunds live bytes/count but
|
|
598
|
+
// not this historical slot: tombstones, mutation leases, and settled generations
|
|
599
|
+
// cannot be time-GCed safely because HTTP invocations have unbounded wall time.
|
|
600
|
+
// The path cap therefore bounds every internal structure under adversarial churn.
|
|
601
|
+
async function reserveMutationPath(env, owner, pathId, limits) {
|
|
602
|
+
return withUsage(env, owner, function (usage) {
|
|
603
|
+
usage.settled = usage.settled && typeof usage.settled === 'object' ? usage.settled : {};
|
|
604
|
+
const hasPath = Object.prototype.hasOwnProperty.call(usage.settled, pathId);
|
|
605
|
+
const derivedCount = Object.keys(usage.settled).length;
|
|
606
|
+
const pathCount = usageNumber(usage.pathCount, derivedCount);
|
|
607
|
+
if (hasPath) {
|
|
608
|
+
if (usage.pathCount === pathCount) return { skip: true };
|
|
609
|
+
usage.pathCount = pathCount;
|
|
610
|
+
return { commit: usage };
|
|
611
|
+
}
|
|
612
|
+
if (pathCount + 1 > limits.maxPaths) {
|
|
613
|
+
return { reject: json({ error: 'historical path limit reached', maxPaths: limits.maxPaths }, 413) };
|
|
614
|
+
}
|
|
615
|
+
usage.pathCount = pathCount + 1;
|
|
616
|
+
usage.settled[pathId] = 0;
|
|
617
|
+
return { commit: usage };
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Apply one idempotent usage delta and retain it under pending[token] until the
|
|
622
|
+
// corresponding object mutation commits or rolls back. A stale lease successor
|
|
623
|
+
// can therefore finish an interrupted two-object transaction without guessing
|
|
624
|
+
// whether the CAS ledger mutation landed.
|
|
625
|
+
async function beginUsageMutation(env, owner, params, limits) {
|
|
626
|
+
if (typeof params.fileBytes === 'number' && params.fileBytes > limits.maxFileBytes) {
|
|
627
|
+
return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: params.fileBytes }, 413);
|
|
628
|
+
}
|
|
629
|
+
return withUsage(env, owner, function (usage) {
|
|
630
|
+
const operation = params.operation;
|
|
631
|
+
usage.pending = usage.pending && typeof usage.pending === 'object' ? usage.pending : {};
|
|
632
|
+
usage.settled = usage.settled && typeof usage.settled === 'object' ? usage.settled : {};
|
|
633
|
+
const settledGeneration = usageNumber(usage.settled[operation.pathId], 0);
|
|
634
|
+
if (settledGeneration >= operation.generation) {
|
|
635
|
+
return { reject: json({ error: 'mutation superseded; retry' }, 409) };
|
|
636
|
+
}
|
|
637
|
+
if (usage.pending[operation.token]) return { commit: usage };
|
|
638
|
+
const requestedCount = typeof params.count === 'number' ? params.count : 0;
|
|
639
|
+
const requestedBytes = typeof params.bytes === 'number' ? params.bytes : 0;
|
|
640
|
+
if (usage.count + requestedCount > limits.maxObjects) {
|
|
641
|
+
return { reject: json({ error: 'artifact limit reached', maxObjects: limits.maxObjects }, 413) };
|
|
642
|
+
}
|
|
643
|
+
if (usage.bytes + requestedBytes > limits.maxBytes) {
|
|
644
|
+
return { reject: json({ error: 'storage limit reached', maxBytes: limits.maxBytes, usedBytes: usage.bytes }, 413) };
|
|
645
|
+
}
|
|
646
|
+
const nextBytes = Math.max(0, usage.bytes + requestedBytes);
|
|
647
|
+
const nextCount = Math.max(0, usage.count + requestedCount);
|
|
648
|
+
usage.pending[operation.token] = {
|
|
649
|
+
bytes: nextBytes - usage.bytes,
|
|
650
|
+
count: nextCount - usage.count,
|
|
651
|
+
pathId: operation.pathId,
|
|
652
|
+
generation: operation.generation,
|
|
653
|
+
};
|
|
654
|
+
usage.bytes = nextBytes;
|
|
655
|
+
usage.count = nextCount;
|
|
656
|
+
return { commit: usage };
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Settle a pending delta and persist a bounded terminal generation for this
|
|
661
|
+
// object path in the SAME CAS ledger. A late predecessor that read an older
|
|
662
|
+
// ledger etag cannot add its delta after recovery: its CAS loses, then begin sees
|
|
663
|
+
// settled[pathId] >= generation and refuses it. One entry per logical path keeps
|
|
664
|
+
// this tombstone bounded independently of overwrite count.
|
|
665
|
+
async function finishUsageMutation(env, owner, operation, rollback) {
|
|
666
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
667
|
+
const state = await readUsage(env, owner);
|
|
668
|
+
state.usage.pending = state.usage.pending && typeof state.usage.pending === 'object'
|
|
669
|
+
? state.usage.pending
|
|
670
|
+
: {};
|
|
671
|
+
state.usage.settled = state.usage.settled && typeof state.usage.settled === 'object'
|
|
672
|
+
? state.usage.settled
|
|
673
|
+
: {};
|
|
674
|
+
const pending = state.usage.pending[operation.token];
|
|
675
|
+
const priorSettled = usageNumber(state.usage.settled[operation.pathId], 0);
|
|
676
|
+
if (!pending && priorSettled >= operation.generation) return true;
|
|
677
|
+
if (rollback && pending) {
|
|
678
|
+
const bytes = typeof pending.bytes === 'number' && Number.isFinite(pending.bytes) ? pending.bytes : 0;
|
|
679
|
+
const count = typeof pending.count === 'number' && Number.isFinite(pending.count) ? pending.count : 0;
|
|
680
|
+
state.usage.bytes = Math.max(0, state.usage.bytes - bytes);
|
|
681
|
+
state.usage.count = Math.max(0, state.usage.count - count);
|
|
682
|
+
}
|
|
683
|
+
delete state.usage.pending[operation.token];
|
|
684
|
+
state.usage.settled[operation.pathId] = Math.max(priorSettled, operation.generation);
|
|
685
|
+
if (await writeUsageCas(env, owner, state.etag, state.usage)) return true;
|
|
686
|
+
}
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Read a request body fully into memory, BOUNDED: abort the moment it exceeds
|
|
691
|
+
// maxBytes. Returns { oversize: true, size } once the cap is passed, else
|
|
692
|
+
// { bytes, size } with the exact bytes.
|
|
693
|
+
async function readBodyBounded(request, maxBytes) {
|
|
694
|
+
if (!request.body || typeof request.body.getReader !== 'function') {
|
|
695
|
+
const buf = await request.arrayBuffer();
|
|
696
|
+
const bytes = new Uint8Array(buf);
|
|
697
|
+
if (bytes.byteLength > maxBytes) return { oversize: true, size: bytes.byteLength };
|
|
698
|
+
return { bytes: bytes, size: bytes.byteLength };
|
|
699
|
+
}
|
|
700
|
+
const reader = request.body.getReader();
|
|
701
|
+
const chunks = [];
|
|
702
|
+
let size = 0;
|
|
703
|
+
while (true) {
|
|
704
|
+
const step = await reader.read();
|
|
705
|
+
if (step.done) break;
|
|
706
|
+
const chunk = step.value;
|
|
707
|
+
size += chunk.byteLength;
|
|
708
|
+
if (size > maxBytes) {
|
|
709
|
+
try { await reader.cancel(); } catch (e) { /* best-effort */ }
|
|
710
|
+
return { oversize: true, size: size };
|
|
711
|
+
}
|
|
712
|
+
chunks.push(chunk);
|
|
713
|
+
}
|
|
714
|
+
const out = new Uint8Array(size);
|
|
715
|
+
let offset = 0;
|
|
716
|
+
for (let i = 0; i < chunks.length; i++) { out.set(chunks[i], offset); offset += chunks[i].byteLength; }
|
|
717
|
+
return { bytes: out, size: size };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Require a Phoenix bearer that owns the path prefix. No public GET, ever.
|
|
721
|
+
// This managed store is Phoenix-only: there is no static-token principal. The
|
|
722
|
+
// zero-knowledge BYO path talks to the operator's OWN R2 bucket directly through
|
|
723
|
+
// the CLI's R2Client and never reaches this Worker, so a WRITE_TOKEN here would
|
|
724
|
+
// be a Phoenix-and-quota bypass with no legitimate caller — it is deliberately
|
|
725
|
+
// absent (PHNX-3726 fix-forward).
|
|
726
|
+
async function authorizeRead(request, env, path) {
|
|
727
|
+
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
728
|
+
if (!claims) {
|
|
729
|
+
return { error: json({ error: 'unauthorized' }, 401) };
|
|
730
|
+
}
|
|
731
|
+
const ownerId = path.split('/').filter(Boolean)[0] || '';
|
|
732
|
+
if (!ownerId || ownerId !== claims.userId) {
|
|
733
|
+
return { error: json({ error: 'forbidden' }, 403) };
|
|
734
|
+
}
|
|
735
|
+
return { claims: claims, owner: claims.userId };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// The single write principal is a Phoenix bearer whose verified userId owns the
|
|
739
|
+
// path prefix. 401 when it does not authenticate.
|
|
740
|
+
async function authorizeWrite(request, env) {
|
|
741
|
+
const claims = await hooks.verifyPhoenixToken(request, env);
|
|
742
|
+
if (claims && typeof claims.userId === 'string' && claims.userId) {
|
|
743
|
+
return { owner: claims.userId };
|
|
744
|
+
}
|
|
745
|
+
return { error: json({ error: 'unauthorized' }, 401) };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// A stored managed body MUST be an ENCRYPTED bundle — never plaintext (SES-51).
|
|
749
|
+
// The upload wire is NDJSON: a header line then one record line per transcript
|
|
750
|
+
// (serializeBundle). This Worker independently re-checks what the CLI already
|
|
751
|
+
// fails-closed on before upload: the header claims encryption AND every record
|
|
752
|
+
// body is a real AES-256-GCM transcript envelope (v/alg/iv/ct/tag), so an
|
|
753
|
+
// older/buggy client's plaintext PUT is rejected 422 rather than landing readable
|
|
754
|
+
// in the bucket. Reserved __key / __usage records are exempt (not user payload).
|
|
755
|
+
function isTranscriptEnvelopeString(body) {
|
|
756
|
+
if (typeof body !== 'string') return false;
|
|
757
|
+
const trimmed = body.trimStart();
|
|
758
|
+
if (!trimmed.startsWith('{')) return false;
|
|
759
|
+
let obj;
|
|
760
|
+
try {
|
|
761
|
+
obj = JSON.parse(body);
|
|
762
|
+
} catch {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
return !!obj
|
|
766
|
+
&& typeof obj === 'object'
|
|
767
|
+
&& obj.v === 1
|
|
768
|
+
&& obj.alg === 'aes-256-gcm'
|
|
769
|
+
&& base64ByteLength(obj.iv) === 12
|
|
770
|
+
&& base64ByteLength(obj.tag) === 16
|
|
771
|
+
&& base64ByteLength(obj.ct) >= 0;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function isEncryptedManagedBundle(bytes) {
|
|
775
|
+
let text;
|
|
776
|
+
try {
|
|
777
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
778
|
+
} catch {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
const lines = text.split(/\\r?\\n/).filter(function (l) { return l.trim().length > 0; });
|
|
782
|
+
if (lines.length < 2) return false; // a header line + at least one record line
|
|
783
|
+
let header;
|
|
784
|
+
try {
|
|
785
|
+
header = JSON.parse(lines[0]);
|
|
786
|
+
} catch {
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
if (!header
|
|
790
|
+
|| header.kind !== 'agents-session-bundle'
|
|
791
|
+
|| header.version !== 1
|
|
792
|
+
|| header.encrypted !== true
|
|
793
|
+
|| header.count !== lines.length - 1
|
|
794
|
+
|| header.count < 1) return false;
|
|
795
|
+
for (let i = 1; i < lines.length; i++) {
|
|
796
|
+
let rec;
|
|
797
|
+
try {
|
|
798
|
+
rec = JSON.parse(lines[i]);
|
|
799
|
+
} catch {
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
if (!rec || rec.encrypted !== true || !isTranscriptEnvelopeString(rec.body)) return false;
|
|
803
|
+
}
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function base64ByteLength(value) {
|
|
808
|
+
if (typeof value !== 'string') return -1;
|
|
809
|
+
try { return atob(value).length; } catch { return -1; }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function defaultVerifyPhoenixToken(request, env) {
|
|
813
|
+
const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
|
|
814
|
+
if (!presented) return null;
|
|
815
|
+
const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
|
|
816
|
+
if (!base) return null;
|
|
817
|
+
let res;
|
|
818
|
+
try {
|
|
819
|
+
res = await fetch(base + '/api/v1/auth/me', {
|
|
820
|
+
method: 'GET',
|
|
821
|
+
headers: { Authorization: 'Bearer ' + presented },
|
|
822
|
+
});
|
|
823
|
+
} catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
if (!res.ok) return null;
|
|
827
|
+
let body;
|
|
828
|
+
try {
|
|
829
|
+
body = await res.json();
|
|
830
|
+
} catch {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
if (!body || typeof body.userId !== 'string' || !body.userId) return null;
|
|
834
|
+
return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function json(body, status = 200) {
|
|
838
|
+
return new Response(JSON.stringify(body), {
|
|
839
|
+
status,
|
|
840
|
+
headers: {
|
|
841
|
+
'content-type': 'application/json; charset=utf-8',
|
|
842
|
+
'cache-control': 'private, no-store',
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
`;
|
|
847
|
+
}
|