@phnx-labs/agents-cli 1.22.62 → 1.22.63
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 +6 -0
- package/dist/commands/feed.d.ts +1 -1
- package/dist/commands/feed.js +2 -1
- package/dist/lib/feed-broadcast.d.ts +9 -0
- package/dist/lib/feed-broadcast.js +21 -1
- package/dist/lib/resource-inventory.d.ts +1 -1
- package/dist/lib/resource-inventory.js +1 -1
- package/dist/lib/share/worker-template.js +268 -12
- package/dist/lib/types.d.ts +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.63
|
|
4
|
+
|
|
5
|
+
- **Managed share endpoint enforces a per-user storage quota, object limit, per-file size cap, and publish rate limit (PHNX-3542).** The managed `share.agents-cli.sh` Worker authenticated any Phoenix ID bearer and then accepted **unbounded** writes into shared R2 — no quota, no rate limit, no size cap — which blocked opening publishing to third parties. Each managed (Phoenix-identity) publish now charges a per-user usage ledger stored in R2 at `__usage/<owner>` (a conditional-put CAS object, mirroring the existing `__views`/`__handles` precedent — no Durable Object, no new binding): free tier is 200 MiB total, 150 canonical pages, 20 MiB per file, and 60 publishes/hour. Enforcement measures the **real request body** (bounded-buffered so a streaming body can't exceed the cap) and rejects on the true size **before any write**, so a spoofed-low declared size can't bypass the caps or destroy an existing page. It **fails loud** — `413` for a file, object-count, or byte-quota overage, `429` (with `Retry-After`) for the rate limit — and refunds bytes + object count on delete and on lazy expiry. Covers/views are server-generated overhead and excluded from the quota. BYO (`WRITE_TOKEN`) publishes write to the operator's own bucket at their own cost and are **unaffected** (a deliberate, documented policy). A `SHARE_PLANS` map is the seam for future paid tiers (billing follow-up PHNX-3569). Source: `cli/src/lib/share/worker-template.ts`.
|
|
6
|
+
|
|
7
|
+
- Let feed channel sinks customize their delivered body with existing post placeholders, including fail-closed `{ticket}` routing for clickable tracker links in team channels.
|
|
8
|
+
|
|
3
9
|
## 1.22.62
|
|
4
10
|
|
|
5
11
|
- **Owner notifications fan out across the configured normal-severity channels (PHNX-3567).** `agents send --to owner`, deprecated `agents notify`, monitor notifications, and an important feed's owner sink now attempt every addressable entry named by `owner.policy.normal` in `humans.yaml`, instead of silently selecting only the first. Each Rush-backed destination that cannot deliver on a Linux worker forwards its explicit channel and target to a capable Mac, avoiding both shell quoting and policy re-expansion/duplicate sends. Partial failures stay visible while successful channels still deliver; legacy single-channel configs retain their old behavior. Source: `cli/src/lib/humans.ts`, `cli/src/lib/notify.ts`, `cli/src/lib/channels/owner-forward.ts`, `cli/src/lib/feed-broadcast.ts`.
|
package/dist/commands/feed.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { type OpenBlock } from '../lib/feed/feed.js';
|
|
|
20
20
|
import { type OutcomeGroup, type SessionOutcomeHint } from '../lib/feed-outcome.js';
|
|
21
21
|
import { filterBlocksForFeed } from '../lib/ask-classifier.js';
|
|
22
22
|
import { type FeedSessionSignal } from '../lib/feed-ranking.js';
|
|
23
|
-
export declare const FEED_POST_HELP = "\nExamples:\n # Title (subject) + body. Phone broadcasts put title first, body after a\n # blank line, then a \"Sent from agent/session on host\" footer.\n agents feed post --title \"CHANGELOG pushed\" \"Watching CI and mac-mini E2E\"\n agents feed post --title \"Cover ready\" \"render at ./out/cover.png\" --attach ./out/cover.png\n agents feed post --title \"Ready for review\" \"PR opened, waiting on prix-cloud\" --json\n\n # Worth interrupting someone over - reaches sinks gated on minLevel: important:\n agents feed post --title \"npm token expired\" \"Cannot publish the release\" --level important\n\n # Also raise a local desktop banner on THIS machine (same notifier as run\n # --notify), on top of any configured broadcast - useful when you are at the box:\n agents feed post --title \"Build green\" \"all checks passed\" --notify\n\n # Stuck: opens a needs-you block and always broadcasts at important:\n agents feed post --title \"Force-push denied\" \"git-guard blocked PR #1749\" --blocked\n agents feed post --title \"Publish or wait?\" \"npm publish now or after review\" --blocked --option publish --option wait\n agents feed post --title \"Delete preview env?\" \"stale preview still running\" --blocked --default \"leave it\"\n\n # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:\n # a credential only the user holds, a decision only they can make, an\n # approval only they can give. Not \"should I do the obvious next step?\".\n\n # Outside a run, pass the session explicitly:\n agents feed post --title \"Manual note\" \"context for the next agent\" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47\n\nIdentity (session, agent, host, runtime, pid, launchId) is stamped automatically\nand rides the phone footer of feed.broadcast {message}. Domain facts (tickets,\nPRs) are not CLI flags - the ticket is joined from the session index at post\ntime. No em-dashes in title/body - they are scrubbed on the way out.\n\nConfigure where a post is mirrored under feed.broadcast in agents.yaml - see\ndocs/observability.md. A milestone is always recorded, but it does not text\nthe owner when the sink has minLevel: important. Add --level important for a\nphone-worthy successful update. Use --blocked only when work cannot continue.\nThe owner destination comes from humans.yaml; do not duplicate it in agents.yaml.\n";
|
|
23
|
+
export declare const FEED_POST_HELP = "\nExamples:\n # Title (subject) + body. Phone broadcasts put title first, body after a\n # blank line, then a \"Sent from agent/session on host\" footer.\n agents feed post --title \"CHANGELOG pushed\" \"Watching CI and mac-mini E2E\"\n agents feed post --title \"Cover ready\" \"render at ./out/cover.png\" --attach ./out/cover.png\n agents feed post --title \"Ready for review\" \"PR opened, waiting on prix-cloud\" --json\n\n # Worth interrupting someone over - reaches sinks gated on minLevel: important:\n agents feed post --title \"npm token expired\" \"Cannot publish the release\" --level important\n\n # Also raise a local desktop banner on THIS machine (same notifier as run\n # --notify), on top of any configured broadcast - useful when you are at the box:\n agents feed post --title \"Build green\" \"all checks passed\" --notify\n\n # Stuck: opens a needs-you block and always broadcasts at important:\n agents feed post --title \"Force-push denied\" \"git-guard blocked PR #1749\" --blocked\n agents feed post --title \"Publish or wait?\" \"npm publish now or after review\" --blocked --option publish --option wait\n agents feed post --title \"Delete preview env?\" \"stale preview still running\" --blocked --default \"leave it\"\n\n # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:\n # a credential only the user holds, a decision only they can make, an\n # approval only they can give. Not \"should I do the obvious next step?\".\n\n # Outside a run, pass the session explicitly:\n agents feed post --title \"Manual note\" \"context for the next agent\" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47\n\nIdentity (session, agent, host, runtime, pid, launchId) is stamped automatically\nand rides the phone footer of feed.broadcast {message}. Domain facts (tickets,\nPRs) are not CLI flags - the ticket is joined from the session index at post\ntime. No em-dashes in title/body - they are scrubbed on the way out.\n\nConfigure where a post is mirrored under feed.broadcast in agents.yaml - see\ndocs/observability.md. A channel sink may set message: with placeholders such\nas {message} and {ticket}; a missing placeholder skips that sink. A milestone is always recorded, but it does not text\nthe owner when the sink has minLevel: important. Add --level important for a\nphone-worthy successful update. Use --blocked only when work cannot continue.\nThe owner destination comes from humans.yaml; do not duplicate it in agents.yaml.\n";
|
|
24
24
|
export declare const FEED_NO_FANOUT_ENV = "AGENTS_FEED_LOCAL";
|
|
25
25
|
/** Right-hand masthead summary: `N blocks · M agents`. */
|
|
26
26
|
export declare function formatFeedMastheadRight(blocks: OpenBlock[]): string;
|
package/dist/commands/feed.js
CHANGED
|
@@ -57,7 +57,8 @@ PRs) are not CLI flags - the ticket is joined from the session index at post
|
|
|
57
57
|
time. No em-dashes in title/body - they are scrubbed on the way out.
|
|
58
58
|
|
|
59
59
|
Configure where a post is mirrored under feed.broadcast in agents.yaml - see
|
|
60
|
-
docs/observability.md. A
|
|
60
|
+
docs/observability.md. A channel sink may set message: with placeholders such
|
|
61
|
+
as {message} and {ticket}; a missing placeholder skips that sink. A milestone is always recorded, but it does not text
|
|
61
62
|
the owner when the sink has minLevel: important. Add --level important for a
|
|
62
63
|
phone-worthy successful update. Use --blocked only when work cannot continue.
|
|
63
64
|
The owner destination comes from humans.yaml; do not duplicate it in agents.yaml.
|
|
@@ -21,6 +21,13 @@ export interface FeedSinkConfig {
|
|
|
21
21
|
channel?: string;
|
|
22
22
|
/** Recipient for a `channel` sink. Required unless `channel` is the `owner` alias. */
|
|
23
23
|
to?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Optional channel body template. Uses the same placeholders as `command`
|
|
26
|
+
* argv (`{message}`, `{ticket}`, `{project}`, ...). Defaults to `{message}`.
|
|
27
|
+
* A missing placeholder skips the sink, which lets a `{ticket}` template
|
|
28
|
+
* declare that only ticket-backed posts belong in that destination.
|
|
29
|
+
*/
|
|
30
|
+
message?: string;
|
|
24
31
|
/** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
|
|
25
32
|
minLevel?: FeedPostLevel;
|
|
26
33
|
}
|
|
@@ -150,6 +157,8 @@ export declare function composeBroadcastMessage(ctx: FeedBroadcastContext): stri
|
|
|
150
157
|
* would otherwise comment on nothing.
|
|
151
158
|
*/
|
|
152
159
|
export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
|
|
160
|
+
/** Render one channel-message template with the same fail-closed placeholder contract as argv. */
|
|
161
|
+
export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext): string | undefined;
|
|
153
162
|
/**
|
|
154
163
|
* Which sinks this post reaches, in config order. Pure — the dry-run listing and
|
|
155
164
|
* the real fan-out plan through here, so what `--dry-run` shows is what runs.
|
|
@@ -324,6 +324,23 @@ export function renderSinkArgv(template, ctx) {
|
|
|
324
324
|
}
|
|
325
325
|
return argv.length > 0 ? argv : undefined;
|
|
326
326
|
}
|
|
327
|
+
/** Render one channel-message template with the same fail-closed placeholder contract as argv. */
|
|
328
|
+
export function renderSinkMessage(template, ctx) {
|
|
329
|
+
const vars = templateVars(ctx);
|
|
330
|
+
let missing = false;
|
|
331
|
+
const rendered = template.replace(PLACEHOLDER, (whole, key) => {
|
|
332
|
+
const value = vars[key];
|
|
333
|
+
if (value === undefined || value === '') {
|
|
334
|
+
missing = true;
|
|
335
|
+
return whole;
|
|
336
|
+
}
|
|
337
|
+
return value;
|
|
338
|
+
});
|
|
339
|
+
if (missing)
|
|
340
|
+
return undefined;
|
|
341
|
+
const text = rendered.trim();
|
|
342
|
+
return text || undefined;
|
|
343
|
+
}
|
|
327
344
|
/**
|
|
328
345
|
* Which sinks this post reaches, in config order. Pure — the dry-run listing and
|
|
329
346
|
* the real fan-out plan through here, so what `--dry-run` shows is what runs.
|
|
@@ -350,11 +367,14 @@ export function planFeedBroadcast(config, ctx) {
|
|
|
350
367
|
// placeholder below).
|
|
351
368
|
if (!isOwnerAlias(channel) && !sink.to?.trim())
|
|
352
369
|
continue;
|
|
370
|
+
const text = renderSinkMessage(sink.message ?? '{message}', ctx);
|
|
371
|
+
if (!text)
|
|
372
|
+
continue;
|
|
353
373
|
planned.push({
|
|
354
374
|
name,
|
|
355
375
|
channel,
|
|
356
376
|
to: isOwnerAlias(channel) ? undefined : sink.to.trim(),
|
|
357
|
-
text
|
|
377
|
+
text,
|
|
358
378
|
});
|
|
359
379
|
continue;
|
|
360
380
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resource inventory — the single chokepoint for "what does this agent@version
|
|
3
|
-
* have" (RUSH-2238, parent RUSH-2236
|
|
3
|
+
* have" (RUSH-2238, parent RUSH-2236). Contract: cli/docs/specifications.md.
|
|
4
4
|
*
|
|
5
5
|
* One API answers four orthogonal questions per (agent, version, kind):
|
|
6
6
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resource inventory — the single chokepoint for "what does this agent@version
|
|
3
|
-
* have" (RUSH-2238, parent RUSH-2236
|
|
3
|
+
* have" (RUSH-2238, parent RUSH-2236). Contract: cli/docs/specifications.md.
|
|
4
4
|
*
|
|
5
5
|
* One API answers four orthogonal questions per (agent, version, kind):
|
|
6
6
|
*
|
|
@@ -251,21 +251,70 @@ export default {
|
|
|
251
251
|
// segments deep (an unsupported shape outside the CLI) landing on the same
|
|
252
252
|
// literal revision key.
|
|
253
253
|
const noRevision = !!request.headers.get('x-share-no-revision');
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
254
|
+
// PHNX-3542: per-user storage quota + object count + per-file size cap +
|
|
255
|
+
// publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
|
|
256
|
+
// WRITE_TOKEN publish writes to the operator's OWN bucket at their own
|
|
257
|
+
// cost, so it skips all four — a deliberate, documented policy, NOT a
|
|
258
|
+
// silent no-op. The current object is needed for BOTH the revision copy and
|
|
259
|
+
// the charge math, so read it once here; a BYO no-revision publish still
|
|
260
|
+
// skips the read entirely (nothing consumes it).
|
|
261
|
+
const needExisting = !noRevision || auth.kind === 'phoenix';
|
|
262
|
+
const existing = needExisting ? await env.BUCKET.get(path) : null;
|
|
263
|
+
// Enforcement measures the REAL request body, never a client-declared size.
|
|
264
|
+
// A spoofed-low content-length must NOT (a) slip an oversized body past the
|
|
265
|
+
// per-file cap, (b) let real bytes exceed the total quota, or — most
|
|
266
|
+
// dangerously — (c) reach the destructive revision-copy + canonical
|
|
267
|
+
// overwrite before the size is known and DESTROY the existing page. So for a
|
|
268
|
+
// Phoenix write we buffer the body bounded by the plan's per-file cap and
|
|
269
|
+
// reject on the REAL size BEFORE any write; only then do we copy the
|
|
270
|
+
// revision and store the buffered bytes. BYO streams unbuffered (uncapped,
|
|
271
|
+
// its own bucket).
|
|
272
|
+
let putBody = request.body;
|
|
273
|
+
if (auth.kind === 'phoenix') {
|
|
274
|
+
const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
|
|
275
|
+
// Fast-reject an HONEST oversized content-length without reading the body.
|
|
276
|
+
// A dishonest (absent or lied-low) length falls through to the bounded
|
|
277
|
+
// read below, which measures the truth.
|
|
278
|
+
const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
|
|
279
|
+
if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
|
|
280
|
+
return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
|
|
281
|
+
}
|
|
282
|
+
// readBodyBounded aborts the moment it passes the cap, so a chunked/
|
|
283
|
+
// streaming body can never buffer more than the cap (+ one chunk).
|
|
284
|
+
const read = await readBodyBounded(request, limits.maxFileBytes);
|
|
285
|
+
if (read.oversize) {
|
|
286
|
+
return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
|
|
265
287
|
}
|
|
288
|
+
const realBytes = read.size;
|
|
289
|
+
const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
|
|
290
|
+
const newCanonical = !existing;
|
|
291
|
+
// Keeping a revision retains the old canonical bytes AND adds the new
|
|
292
|
+
// ones, so storage grows by the full new size. A no-revision or first
|
|
293
|
+
// publish grows by new minus the bytes it replaces (may be negative on a
|
|
294
|
+
// shrink; the ledger clamps at >= 0).
|
|
295
|
+
const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
|
|
296
|
+
const charged = await chargeShareWrite(env, auth, {
|
|
297
|
+
charge: charge,
|
|
298
|
+
newCanonical: newCanonical,
|
|
299
|
+
fileBytes: realBytes,
|
|
300
|
+
countRate: true,
|
|
301
|
+
});
|
|
302
|
+
if (charged.error) return charged.error; // rejected BEFORE any destructive write
|
|
303
|
+
putBody = read.bytes;
|
|
266
304
|
}
|
|
267
305
|
|
|
268
|
-
|
|
306
|
+
if (!noRevision && existing) {
|
|
307
|
+
const existingHeaders = new Headers();
|
|
308
|
+
if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
|
|
309
|
+
const existingContentType = existingHeaders.get('content-type');
|
|
310
|
+
const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
311
|
+
await env.BUCKET.put(revKey, existing.body, {
|
|
312
|
+
httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
|
|
313
|
+
customMetadata: existing.customMetadata || {},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await env.BUCKET.put(path, putBody, {
|
|
269
318
|
httpMetadata: { contentType },
|
|
270
319
|
customMetadata,
|
|
271
320
|
});
|
|
@@ -508,6 +557,22 @@ export default {
|
|
|
508
557
|
const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
|
|
509
558
|
if (expiresAt && Date.now() > Date.parse(expiresAt)) {
|
|
510
559
|
await env.BUCKET.delete(path);
|
|
560
|
+
// PHNX-3542: expiry is a lazy delete with no auth context. Refund the
|
|
561
|
+
// stamped owner's quota (bytes + object count) best-effort — a refund
|
|
562
|
+
// failure must never break serving the 410. A canonical page is 2
|
|
563
|
+
// segments; covers are excluded (never charged) so refund is safe here
|
|
564
|
+
// because only canonical pages ever carry an expires-at.
|
|
565
|
+
const expiredOwner = obj.customMetadata && obj.customMetadata['owner'];
|
|
566
|
+
if (expiredOwner && !path.endsWith('.png')) {
|
|
567
|
+
try {
|
|
568
|
+
await refundShareWrite(env, expiredOwner, {
|
|
569
|
+
refund: typeof obj.size === 'number' ? obj.size : 0,
|
|
570
|
+
freeCanonical: path.split('/').filter(Boolean).length === 2,
|
|
571
|
+
});
|
|
572
|
+
} catch (e) {
|
|
573
|
+
// best-effort: serving the expiry 410 must never depend on the refund
|
|
574
|
+
}
|
|
575
|
+
}
|
|
511
576
|
return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
|
|
512
577
|
}
|
|
513
578
|
// Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
|
|
@@ -615,7 +680,24 @@ export default {
|
|
|
615
680
|
return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
|
|
616
681
|
}
|
|
617
682
|
}
|
|
683
|
+
// PHNX-3542: capture the object's size BEFORE deleting so a managed
|
|
684
|
+
// Phoenix owner's quota can be refunded. A canonical page (2 segments, not
|
|
685
|
+
// a .png cover) refunds both bytes and the object count; a retained
|
|
686
|
+
// revision (3+ segments) refunds bytes only; a server-generated .png cover
|
|
687
|
+
// was never charged, so it is skipped entirely.
|
|
688
|
+
let doomed = null;
|
|
689
|
+
const isCover = path.endsWith('.png');
|
|
690
|
+
if (auth.kind === 'phoenix' && !isCover && !firstSeg.startsWith('__')) {
|
|
691
|
+
doomed = typeof env.BUCKET.head === 'function' ? await env.BUCKET.head(path) : await env.BUCKET.get(path);
|
|
692
|
+
}
|
|
618
693
|
await env.BUCKET.delete(path);
|
|
694
|
+
if (doomed) {
|
|
695
|
+
const delSegs = path.split('/').filter(Boolean);
|
|
696
|
+
await refundShareWrite(env, auth.owner, {
|
|
697
|
+
refund: typeof doomed.size === 'number' ? doomed.size : 0,
|
|
698
|
+
freeCanonical: delSegs.length === 2,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
619
701
|
return json({ ok: true, deleted: path }, 200);
|
|
620
702
|
}
|
|
621
703
|
|
|
@@ -819,6 +901,23 @@ var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotma
|
|
|
819
901
|
var SHARE_COOKIE = '__Host-phoenix_share';
|
|
820
902
|
var SHARE_COOKIE_MAX_AGE = 604800;
|
|
821
903
|
|
|
904
|
+
// PHNX-3542 — per-user storage limits for the MANAGED share endpoint. The plan
|
|
905
|
+
// map is the seam for future paid tiers: only 'free' is defined today, and a
|
|
906
|
+
// ledger with no plan (or an unknown one) resolves to it via planLimits(). Paid
|
|
907
|
+
// tiers and the write path that sets a user's plan arrive with billing (follow-up
|
|
908
|
+
// PHNX-3569); this is a legitimately-deferred seam, not a stub — 'free' IS
|
|
909
|
+
// enforced. Covers/views are server-generated overhead and excluded from the
|
|
910
|
+
// quota, which counts canonical pages + their retained revisions only.
|
|
911
|
+
var MiB = 1024 * 1024;
|
|
912
|
+
var SHARE_PLANS = {
|
|
913
|
+
free: { maxBytes: 200 * MiB, maxObjects: 150, maxFileBytes: 20 * MiB, ratePerHour: 60 },
|
|
914
|
+
};
|
|
915
|
+
var DEFAULT_SHARE_PLAN = 'free';
|
|
916
|
+
var RATE_WINDOW_MS = 3600 * 1000; // fixed 1h publish-rate window
|
|
917
|
+
function planLimits(plan) { return SHARE_PLANS[plan] || SHARE_PLANS[DEFAULT_SHARE_PLAN]; }
|
|
918
|
+
function freshUsage() { return { bytes: 0, count: 0, plan: DEFAULT_SHARE_PLAN, rlStart: 0, rlUsed: 0 }; }
|
|
919
|
+
function usageNumber(v, fallback) { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback; }
|
|
920
|
+
|
|
822
921
|
// Everything in customMetadata that ISN'T one of the reserved provenance/label
|
|
823
922
|
// keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
|
|
824
923
|
// every read route (listing, revisions) so a value stored with \`--meta
|
|
@@ -1313,6 +1412,163 @@ async function writeViews(env, path, count) {
|
|
|
1313
1412
|
}
|
|
1314
1413
|
}
|
|
1315
1414
|
|
|
1415
|
+
// --- PHNX-3542 per-user usage ledger (R2 conditional-put CAS) ---------------
|
|
1416
|
+
// The ledger is a single R2 object at __usage/<owner> holding the running
|
|
1417
|
+
// { bytes, count, plan, rlStart, rlUsed } for one user. It mirrors the __views /
|
|
1418
|
+
// __handles precedent: a __-prefixed key, GET-blocked and outside every gallery/
|
|
1419
|
+
// listing/revision prefix. R2 has no atomic increment, so mutations use a
|
|
1420
|
+
// read → mutate → conditional-put loop (onlyIf.etagMatches), the same primitive
|
|
1421
|
+
// the PATCH metadata-edit path already relies on.
|
|
1422
|
+
function usageKey(owner) { return '__usage/' + sanitizeNamespace(owner); }
|
|
1423
|
+
|
|
1424
|
+
async function readUsage(env, owner) {
|
|
1425
|
+
const obj = await env.BUCKET.get(usageKey(owner));
|
|
1426
|
+
if (!obj) return { etag: null, usage: freshUsage() };
|
|
1427
|
+
let usage = freshUsage();
|
|
1428
|
+
try {
|
|
1429
|
+
const raw = typeof obj.text === 'function' ? await obj.text() : '';
|
|
1430
|
+
const parsed = JSON.parse(raw);
|
|
1431
|
+
if (parsed && typeof parsed === 'object') {
|
|
1432
|
+
usage = {
|
|
1433
|
+
bytes: usageNumber(parsed.bytes, 0),
|
|
1434
|
+
count: usageNumber(parsed.count, 0),
|
|
1435
|
+
plan: typeof parsed.plan === 'string' ? parsed.plan : DEFAULT_SHARE_PLAN,
|
|
1436
|
+
rlStart: usageNumber(parsed.rlStart, 0),
|
|
1437
|
+
rlUsed: usageNumber(parsed.rlUsed, 0),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
// Malformed ledger — treat as fresh zero but KEEP the etag so the next CAS
|
|
1442
|
+
// put overwrites the corrupt object rather than looping against it forever.
|
|
1443
|
+
}
|
|
1444
|
+
return { etag: obj.etag || null, usage: usage };
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async function writeUsageCas(env, owner, prevEtag, usage) {
|
|
1448
|
+
const body = JSON.stringify(usage);
|
|
1449
|
+
const opts = { httpMetadata: { contentType: 'application/json' } };
|
|
1450
|
+
if (prevEtag) {
|
|
1451
|
+
// Existing object: conditional put. R2 returns null when the etag no longer
|
|
1452
|
+
// matches (a concurrent writer won the race) → report failure so the caller
|
|
1453
|
+
// re-reads and retries.
|
|
1454
|
+
opts.onlyIf = { etagMatches: prevEtag };
|
|
1455
|
+
const res = await env.BUCKET.put(usageKey(owner), body, opts);
|
|
1456
|
+
return res !== null;
|
|
1457
|
+
}
|
|
1458
|
+
// Fresh key: plain create. Neither R2 nor the test harness exposes a
|
|
1459
|
+
// conditional-create predicate, so the only race is two simultaneous
|
|
1460
|
+
// first-creates of the same owner's ledger — a benign, one-time bounded loss.
|
|
1461
|
+
await env.BUCKET.put(usageKey(owner), body, opts);
|
|
1462
|
+
return true;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// CAS retry loop. fn(usage) returns { reject: Response } to fail loud without
|
|
1466
|
+
// committing, or { commit: usage, result } to persist and return result. On CAS
|
|
1467
|
+
// contention it re-reads and retries; exhausting the retries fails loud with 503.
|
|
1468
|
+
async function withUsage(env, owner, fn) {
|
|
1469
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1470
|
+
const state = await readUsage(env, owner);
|
|
1471
|
+
const outcome = fn(state.usage);
|
|
1472
|
+
if (outcome.reject) return outcome.reject;
|
|
1473
|
+
const ok = await writeUsageCas(env, owner, state.etag, outcome.commit);
|
|
1474
|
+
if (ok) return outcome.result;
|
|
1475
|
+
}
|
|
1476
|
+
return json({ error: 'usage ledger contended, retry' }, 503);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function rateLimited(retryAfterSec, ratePerHour) {
|
|
1480
|
+
return new Response(
|
|
1481
|
+
JSON.stringify({ error: 'rate limit: too many publishes, retry later', retryAfterSec: retryAfterSec, ratePerHour: ratePerHour }),
|
|
1482
|
+
{ status: 429, headers: { 'content-type': 'application/json', 'retry-after': String(retryAfterSec) } },
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Charge one authed PUT against the owner's ledger. Rate → per-file cap → object
|
|
1487
|
+
// count → byte quota, each failing loud (429 / 413) before anything is written.
|
|
1488
|
+
// Returns { error: Response } on any rejection, else { limits } (the resolved
|
|
1489
|
+
// plan limits, reused by the post-write real-size reconcile). Managed only: the
|
|
1490
|
+
// caller guards on auth.kind === 'phoenix'.
|
|
1491
|
+
async function chargeShareWrite(env, auth, params) {
|
|
1492
|
+
const now = Date.now();
|
|
1493
|
+
const out = await withUsage(env, auth.owner, function (usage) {
|
|
1494
|
+
const limits = planLimits(usage.plan);
|
|
1495
|
+
// Rate limit — only a user-initiated page PUT counts (countRate). A fixed 1h
|
|
1496
|
+
// window: reset when the window has rolled over, otherwise reject at the cap.
|
|
1497
|
+
if (params.countRate) {
|
|
1498
|
+
if (now - usage.rlStart >= RATE_WINDOW_MS) { usage.rlStart = now; usage.rlUsed = 0; }
|
|
1499
|
+
if (usage.rlUsed >= limits.ratePerHour) {
|
|
1500
|
+
const retryAfterSec = Math.max(1, Math.ceil((usage.rlStart + RATE_WINDOW_MS - now) / 1000));
|
|
1501
|
+
return { reject: rateLimited(retryAfterSec, limits.ratePerHour) };
|
|
1502
|
+
}
|
|
1503
|
+
usage.rlUsed += 1;
|
|
1504
|
+
}
|
|
1505
|
+
// Per-file size cap.
|
|
1506
|
+
if (typeof params.fileBytes === 'number' && params.fileBytes > limits.maxFileBytes) {
|
|
1507
|
+
return { reject: json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: params.fileBytes }, 413) };
|
|
1508
|
+
}
|
|
1509
|
+
// Object (canonical page) count.
|
|
1510
|
+
if (params.newCanonical) {
|
|
1511
|
+
if (usage.count + 1 > limits.maxObjects) {
|
|
1512
|
+
return { reject: json({ error: 'artifact limit reached', maxObjects: limits.maxObjects }, 413) };
|
|
1513
|
+
}
|
|
1514
|
+
usage.count += 1;
|
|
1515
|
+
}
|
|
1516
|
+
// Total byte quota. charge may be negative on a shrink — clamp at >= 0.
|
|
1517
|
+
if (usage.bytes + params.charge > limits.maxBytes) {
|
|
1518
|
+
return { reject: json({ error: 'storage limit reached', maxBytes: limits.maxBytes, usedBytes: usage.bytes }, 413) };
|
|
1519
|
+
}
|
|
1520
|
+
usage.bytes = Math.max(0, usage.bytes + params.charge);
|
|
1521
|
+
return { commit: usage, result: { limits: limits } };
|
|
1522
|
+
});
|
|
1523
|
+
if (out instanceof Response) return { error: out };
|
|
1524
|
+
return { limits: out.limits };
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// Read a request body fully into memory, BOUNDED: abort the moment it exceeds
|
|
1528
|
+
// maxBytes, so a chunked/streaming body can never buffer more than the cap (plus
|
|
1529
|
+
// one in-flight chunk) and OOM the Worker. Returns { oversize: true, size } once
|
|
1530
|
+
// the cap is passed (size is a lower bound, >= cap), else { bytes, size } with
|
|
1531
|
+
// the exact bytes. A body-less request yields an empty buffer. This is what lets
|
|
1532
|
+
// enforcement key on the REAL size instead of a spoofable declared header.
|
|
1533
|
+
async function readBodyBounded(request, maxBytes) {
|
|
1534
|
+
if (!request.body || typeof request.body.getReader !== 'function') {
|
|
1535
|
+
const buf = await request.arrayBuffer();
|
|
1536
|
+
const bytes = new Uint8Array(buf);
|
|
1537
|
+
if (bytes.byteLength > maxBytes) return { oversize: true, size: bytes.byteLength };
|
|
1538
|
+
return { bytes: bytes, size: bytes.byteLength };
|
|
1539
|
+
}
|
|
1540
|
+
const reader = request.body.getReader();
|
|
1541
|
+
const chunks = [];
|
|
1542
|
+
let size = 0;
|
|
1543
|
+
while (true) {
|
|
1544
|
+
const step = await reader.read();
|
|
1545
|
+
if (step.done) break;
|
|
1546
|
+
const chunk = step.value;
|
|
1547
|
+
size += chunk.byteLength;
|
|
1548
|
+
if (size > maxBytes) {
|
|
1549
|
+
try { await reader.cancel(); } catch (e) { /* best-effort */ }
|
|
1550
|
+
return { oversize: true, size: size };
|
|
1551
|
+
}
|
|
1552
|
+
chunks.push(chunk);
|
|
1553
|
+
}
|
|
1554
|
+
const out = new Uint8Array(size);
|
|
1555
|
+
let offset = 0;
|
|
1556
|
+
for (let i = 0; i < chunks.length; i++) { out.set(chunks[i], offset); offset += chunks[i].byteLength; }
|
|
1557
|
+
return { bytes: out, size: size };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Refund on DELETE / expiry. Never rejects, never creates a ledger: if the owner
|
|
1561
|
+
// was never charged (no __usage object) it is a pure no-op.
|
|
1562
|
+
async function refundShareWrite(env, owner, params) {
|
|
1563
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
1564
|
+
const state = await readUsage(env, owner);
|
|
1565
|
+
if (!state.etag) return; // no ledger — owner never charged, nothing to refund
|
|
1566
|
+
state.usage.bytes = Math.max(0, state.usage.bytes - (params.refund || 0));
|
|
1567
|
+
if (params.freeCanonical) state.usage.count = Math.max(0, state.usage.count - 1);
|
|
1568
|
+
if (await writeUsageCas(env, owner, state.etag, state.usage)) return;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1316
1572
|
function viewerMayRead(visibility, meta, identity) {
|
|
1317
1573
|
if (visibility === 'me') {
|
|
1318
1574
|
const owner = meta && meta.owner;
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -929,7 +929,9 @@ export interface Meta {
|
|
|
929
929
|
/**
|
|
930
930
|
* `agents feed post` fan-out. `broadcast` maps a sink name to either an argv
|
|
931
931
|
* template (`command:`, run for each post) or an in-process channel delivery
|
|
932
|
-
* (`channel:`, the same registry `agents send`/`agents notify` use)
|
|
932
|
+
* (`channel:`, the same registry `agents send`/`agents notify` use). Channel
|
|
933
|
+
* sinks may set `message:` with feed placeholders; a missing placeholder
|
|
934
|
+
* skips that sink, so `{ticket}` cleanly gates a tracker-specific channel. Thus
|
|
933
935
|
* mirroring to a tracker, a messaging CLI, or a channel provider is the
|
|
934
936
|
* operator's config rather than an integration compiled into this CLI. When
|
|
935
937
|
* this is unset/empty, an important-level post falls back to `notify.owner`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.63",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|