@phnx-labs/agents-cli 1.22.61 → 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 +10 -0
- package/README.md +6 -0
- package/dist/commands/feed.d.ts +1 -1
- package/dist/commands/feed.js +5 -2
- package/dist/commands/monitors.js +3 -3
- package/dist/commands/send.js +4 -1
- package/dist/lib/channels/owner-forward.d.ts +14 -8
- package/dist/lib/channels/owner-forward.js +9 -5
- package/dist/lib/channels/registry.d.ts +2 -0
- package/dist/lib/channels/send.js +13 -1
- package/dist/lib/feed-broadcast.d.ts +9 -0
- package/dist/lib/feed-broadcast.js +26 -13
- package/dist/lib/humans.d.ts +9 -0
- package/dist/lib/humans.js +29 -8
- package/dist/lib/notify.d.ts +3 -0
- package/dist/lib/notify.js +63 -21
- 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,15 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.22.62
|
|
10
|
+
|
|
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`.
|
|
12
|
+
|
|
3
13
|
## 1.22.61
|
|
4
14
|
|
|
5
15
|
- **`agents harness` wizard: model catalog, connection test, and edit matrix (PHNX-2218/2220/2221/2222).** The create/edit wizard now picks the model from the host's own catalog (`getModelCatalog`) with a free-text escape hatch, gates the endpoint step to hosts that actually carry one, and runs a real pre-save connection test — `agents run <name> "say alive in one word" --headless --timeout 60s` classified into pass / auth / endpoint / model — behind a confirm with `--test`/`--no-test`, offering keep / edit / delete on failure rather than saving a broken harness silently. A resolver-sourced `harnessEditable` matrix disables (with a reason) any param the host's API format can't carry. Source: `apps/cli/src/commands/harness.ts`, `apps/cli/src/commands/harness-wizard.ts`, `apps/cli/src/commands/harness-hooks.ts`, `apps/cli/src/lib/harness-connection-test.ts`.
|
package/README.md
CHANGED
|
@@ -461,6 +461,12 @@ agents feed answer <key> --choice 0 # first answer wins; route over the recorded
|
|
|
461
461
|
agents feed post --title "Halfway done" "CI green, watching merge" # title + body
|
|
462
462
|
```
|
|
463
463
|
|
|
464
|
+
Important posts can reach every owner channel named in
|
|
465
|
+
`~/.agents/humans.yaml` under `owner.policy.normal` (for example iMessage and
|
|
466
|
+
Slack). Owner delivery attempts every selected channel, reports partial
|
|
467
|
+
failures, and safely forwards Rush-backed channels from headless workers to a
|
|
468
|
+
capable Mac; ordinary milestone posts remain record-only unless configured.
|
|
469
|
+
|
|
464
470
|
Top-level questions and waiting notifications publish one atomic open-block record per session, including the mailbox id, host, runtime, and every answer option. The default view collapses agents under the **outcome** they serve (Linear ticket, PR, worktree slug, or Unassigned) so a 1,100-agent fleet reads as dozens of deliverables. Answered, resumed, and stopped blocks clear automatically; Task subagents are excluded. The rendered reply command uses the same mailbox id with `agents message`, so the decision routes back to the agent that asked it.
|
|
465
471
|
|
|
466
472
|
### Auto-nudge stalls
|
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.
|
|
@@ -726,7 +727,9 @@ async function broadcastBlock(block, extras, meta, notify = false) {
|
|
|
726
727
|
/** One line per sink that ran. Silent when nothing is configured. */
|
|
727
728
|
function reportBroadcast(outcomes) {
|
|
728
729
|
for (const o of outcomes) {
|
|
729
|
-
if (o.ok)
|
|
730
|
+
if (o.ok && o.error)
|
|
731
|
+
console.error(chalk.yellow(` → ${o.name} partial: ${o.error}`));
|
|
732
|
+
else if (o.ok)
|
|
730
733
|
console.log(chalk.gray(` → ${o.name}`));
|
|
731
734
|
else
|
|
732
735
|
console.error(chalk.yellow(` → ${o.name} failed: ${o.error}`));
|
|
@@ -425,8 +425,8 @@ function buildAction(options) {
|
|
|
425
425
|
}
|
|
426
426
|
if (options.notify !== undefined) {
|
|
427
427
|
// --notify may be a bare flag (notify the owner) or carry a channel that
|
|
428
|
-
//
|
|
429
|
-
//
|
|
428
|
+
// selects one channel. Left unset, the send fans out every addressable
|
|
429
|
+
// owner.policy.normal destination from humans.yaml (one source of truth).
|
|
430
430
|
const channel = typeof options.notify === 'string' ? options.notify : undefined;
|
|
431
431
|
chosen.push({ type: 'notify', ...(channel ? { notifyChannel: channel } : {}) });
|
|
432
432
|
}
|
|
@@ -620,7 +620,7 @@ export function registerMonitorsCommands(program) {
|
|
|
620
620
|
.option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
|
|
621
621
|
.option('--postcondition <cmd>', 'Shell command that must exit 0 after a --run/--routine action settles; otherwise the fire records as no-effect, not ok. {event} is replaced with the fired event summary')
|
|
622
622
|
.option('--routine <name>', 'Fire an existing routine on change')
|
|
623
|
-
.option('--notify [channel]', 'Notify
|
|
623
|
+
.option('--notify [channel]', 'Notify every normal-policy owner channel; [channel] selects one channel')
|
|
624
624
|
.option('--webhook-out <url>', 'POST the event to this URL')
|
|
625
625
|
// PLACEMENT / hygiene
|
|
626
626
|
.option('--device <name>', 'OWNER (not body placement) — the single machine that evaluates + fires (exactly-once). See docs/concepts.md#placement.')
|
package/dist/commands/send.js
CHANGED
|
@@ -44,6 +44,8 @@ async function runSend(positionalText, opts, ownerMode) {
|
|
|
44
44
|
const suffix = result.msgId ? chalk.dim(` (${result.msgId})`) : '';
|
|
45
45
|
const dry = envelope.dryRun ? chalk.dim(' [dry-run]') : '';
|
|
46
46
|
console.log(chalk.green(`Sent via ${result.channel} → ${result.id}`) + suffix + dry);
|
|
47
|
+
if (result.error)
|
|
48
|
+
console.error(chalk.yellow(`Partial delivery failure: ${result.error}`));
|
|
47
49
|
}
|
|
48
50
|
const SHARED_NOTES = `
|
|
49
51
|
Planes (do not mix them up):
|
|
@@ -122,7 +124,8 @@ export function registerSendCommand(program) {
|
|
|
122
124
|
notes: `
|
|
123
125
|
DEPRECATED. notify ≡ send --to owner and still works, but new callers
|
|
124
126
|
should use "agents feed post" (record + optional broadcast) instead.
|
|
125
|
-
Set
|
|
127
|
+
Set owner.channels + owner.policy.normal in humans.yaml once per fleet.
|
|
128
|
+
Every channel listed in the normal policy receives an owner-addressed send.
|
|
126
129
|
|
|
127
130
|
${SHARED_NOTES}
|
|
128
131
|
`,
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* This mirrors the SSH reroute `agents message` (decideHostTaskRoute →
|
|
15
15
|
* runOnPeer) and the sessions fan-out already use for work that lives on another
|
|
16
16
|
* box: pick a reachable peer from the device registry and run the same `agents`
|
|
17
|
-
* verb there. Here the verb is `agents send --
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* verb there. Here the verb is `agents send --channel <channel> --to <target>`.
|
|
18
|
+
* Keeping the destination explicit and delivering through the peer's local rush
|
|
19
|
+
* destination explicit prevents a multi-channel owner policy from expanding a
|
|
20
|
+
* second time on the peer and duplicating already-successful channels.
|
|
20
21
|
*
|
|
21
22
|
* Best-effort seam: it never throws and never blocks the post. When no capable
|
|
22
23
|
* peer is reachable it resolves `undefined` and the caller keeps its original
|
|
@@ -63,13 +64,17 @@ export declare function planOwnerForward(channel: string, meta: Meta, devices: D
|
|
|
63
64
|
}): OwnerForwardPlan;
|
|
64
65
|
/**
|
|
65
66
|
* Deliver `text` to the owner FROM one peer over SSH. Runs the peer's own
|
|
66
|
-
* `agents send --to
|
|
67
|
-
*
|
|
67
|
+
* `agents send --channel <channel> --to <target> --text <text> --json`, which
|
|
68
|
+
* delivers the already-resolved destination through its local provider.
|
|
68
69
|
* Resolves the parsed `SendResult`, or `undefined` when the peer is
|
|
69
70
|
* unreachable / not a dialable device / answered with unparseable output —
|
|
70
71
|
* every one of which means "try the next peer".
|
|
71
72
|
*/
|
|
72
|
-
export
|
|
73
|
+
export interface PeerOwnerEnvelope {
|
|
74
|
+
thread?: string;
|
|
75
|
+
from?: string;
|
|
76
|
+
}
|
|
77
|
+
export type PeerOwnerSender = (machine: string, text: string, channel: string, target: string, envelope?: PeerOwnerEnvelope) => Promise<SendResult | undefined>;
|
|
73
78
|
/**
|
|
74
79
|
* Try each capable peer in order and return the first successful delivery. A
|
|
75
80
|
* peer that is unreachable or reports its own delivery failure is skipped and
|
|
@@ -79,10 +84,11 @@ export type PeerOwnerSender = (machine: string, text: string) => Promise<SendRes
|
|
|
79
84
|
*
|
|
80
85
|
* The transport (`send`) is injectable so the try-order / first-success / stop
|
|
81
86
|
* orchestration is testable without a live SSH host; the default runs the real
|
|
82
|
-
* `agents send
|
|
87
|
+
* explicit-destination `agents send` over SSH.
|
|
83
88
|
*/
|
|
84
|
-
export declare function forwardOwnerNotifyToPeer(text: string, channel: string, meta: Meta, opts?: {
|
|
89
|
+
export declare function forwardOwnerNotifyToPeer(text: string, channel: string, target: string, meta: Meta, opts?: {
|
|
85
90
|
self?: string;
|
|
86
91
|
devices?: DeviceProfile[];
|
|
87
92
|
send?: PeerOwnerSender;
|
|
93
|
+
envelope?: PeerOwnerEnvelope;
|
|
88
94
|
}): Promise<SendResult | undefined>;
|
|
@@ -51,11 +51,15 @@ export function planOwnerForward(channel, meta, devices, self, opts = {}) {
|
|
|
51
51
|
return { candidates: [], skip: 'no-capable-peer' };
|
|
52
52
|
return { candidates };
|
|
53
53
|
}
|
|
54
|
-
async function sendOnPeer(machine, text) {
|
|
54
|
+
async function sendOnPeer(machine, text, channel, target, envelope = {}) {
|
|
55
55
|
const peer = await resolvePeerTarget(machine);
|
|
56
56
|
if (!peer)
|
|
57
57
|
return undefined;
|
|
58
|
-
const args = ['send', '--
|
|
58
|
+
const args = ['send', '--channel', channel, '--to', target, '--text', text, '--json'];
|
|
59
|
+
if (envelope.thread)
|
|
60
|
+
args.push('--thread', envelope.thread);
|
|
61
|
+
if (envelope.from)
|
|
62
|
+
args.push('--from', envelope.from);
|
|
59
63
|
// Reuse the one injection-tested remote-command builder every `--device`
|
|
60
64
|
// dispatch uses (posix `bash -lc` / Windows `-EncodedCommand`), rather than a
|
|
61
65
|
// second hand-rolled quoting path on a security-sensitive seam. The env map is
|
|
@@ -83,9 +87,9 @@ async function sendOnPeer(machine, text) {
|
|
|
83
87
|
*
|
|
84
88
|
* The transport (`send`) is injectable so the try-order / first-success / stop
|
|
85
89
|
* orchestration is testable without a live SSH host; the default runs the real
|
|
86
|
-
* `agents send
|
|
90
|
+
* explicit-destination `agents send` over SSH.
|
|
87
91
|
*/
|
|
88
|
-
export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
|
|
92
|
+
export async function forwardOwnerNotifyToPeer(text, channel, target, meta, opts = {}) {
|
|
89
93
|
// Cheap, I/O-free gate first: a box that already received a forward, or an
|
|
90
94
|
// owner channel that isn't the macOS-only rush family, can never forward — so
|
|
91
95
|
// a normal local success/failure never pays a device-registry disk read.
|
|
@@ -108,7 +112,7 @@ export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
|
|
|
108
112
|
return undefined;
|
|
109
113
|
const send = opts.send ?? sendOnPeer;
|
|
110
114
|
for (const machine of plan.candidates) {
|
|
111
|
-
const result = await send(machine, text);
|
|
115
|
+
const result = await send(machine, text, channel, target, opts.envelope);
|
|
112
116
|
if (result?.ok)
|
|
113
117
|
return result;
|
|
114
118
|
}
|
|
@@ -33,6 +33,8 @@ export interface SendResult {
|
|
|
33
33
|
attachments?: string[];
|
|
34
34
|
/** Mailbox provider returns the enqueued message id. */
|
|
35
35
|
msgId?: string;
|
|
36
|
+
/** Per-destination results when the owner policy selects multiple channels. */
|
|
37
|
+
deliveries?: SendResult[];
|
|
36
38
|
}
|
|
37
39
|
export interface ChannelProvider {
|
|
38
40
|
/** Stable name used in `--channel` and as a `notify.transports` value. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getOwnerNotifyFromHumans } from '../humans.js';
|
|
2
2
|
import { registerBuiltinProviders } from './providers/index.js';
|
|
3
3
|
import { resolveTransport } from './resolve.js';
|
|
4
|
+
import { sendToOwner } from '../notify.js';
|
|
4
5
|
const OWNER_ALIAS = 'owner';
|
|
5
6
|
/** True when the destination token means “the configured owner”. */
|
|
6
7
|
export function isOwnerAlias(to) {
|
|
@@ -112,6 +113,17 @@ export async function sendMessage(input, meta) {
|
|
|
112
113
|
const resolved = resolveSendEnvelope(input, meta);
|
|
113
114
|
if (!resolved.ok)
|
|
114
115
|
return { error: resolved.error };
|
|
115
|
-
const
|
|
116
|
+
const ownerPolicyRequest = resolved.envelope.ownerScoped === true
|
|
117
|
+
&& !input.channel?.trim()
|
|
118
|
+
&& (!input.to?.trim() || isOwnerAlias(input.to));
|
|
119
|
+
const result = ownerPolicyRequest
|
|
120
|
+
? await sendToOwner(resolved.envelope.text, {
|
|
121
|
+
meta,
|
|
122
|
+
dryRun: resolved.envelope.dryRun,
|
|
123
|
+
thread: resolved.envelope.thread,
|
|
124
|
+
attachments: resolved.envelope.attachments,
|
|
125
|
+
from: resolved.envelope.from,
|
|
126
|
+
})
|
|
127
|
+
: await deliverEnvelope(resolved.envelope, meta);
|
|
116
128
|
return { result, envelope: resolved.envelope };
|
|
117
129
|
}
|
|
@@ -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.
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
import { spawnSync } from 'child_process';
|
|
37
37
|
import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
|
|
38
38
|
import { lookupTransport } from './channels/resolve.js';
|
|
39
|
-
import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
|
|
40
39
|
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
40
|
+
import { sendToOwner } from './notify.js';
|
|
41
41
|
const LEVEL_RANK = { milestone: 0, important: 1 };
|
|
42
42
|
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
|
|
43
43
|
export function parseFeedPostLevel(raw) {
|
|
@@ -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
|
}
|
|
@@ -462,6 +482,10 @@ async function runChannelSink(sink, meta) {
|
|
|
462
482
|
// for a name that is, in fact, registered.
|
|
463
483
|
registerBuiltinProviders();
|
|
464
484
|
const owner = isOwnerAlias(sink.channel);
|
|
485
|
+
if (owner) {
|
|
486
|
+
const result = await sendToOwner(sink.text ?? '', { meta });
|
|
487
|
+
return { name, ok: result.ok, ...(result.error ? { error: result.error } : {}) };
|
|
488
|
+
}
|
|
465
489
|
const resolved = resolveSendEnvelope({
|
|
466
490
|
text: sink.text ?? '',
|
|
467
491
|
channel: owner ? undefined : sink.channel,
|
|
@@ -476,17 +500,6 @@ async function runChannelSink(sink, meta) {
|
|
|
476
500
|
const result = await deliverEnvelope(resolved.envelope, meta);
|
|
477
501
|
if (result.ok)
|
|
478
502
|
return { name, ok: true };
|
|
479
|
-
// The owner sink failed locally. When this box structurally cannot reach the
|
|
480
|
-
// owner (the rush-backed channel is macOS-only, so a headless Linux worker can
|
|
481
|
-
// never ring the phone — PHNX-3303), hand the delivery to a capable fleet peer
|
|
482
|
-
// over SSH rather than stranding the important post. Only the `owner` alias
|
|
483
|
-
// forwards: it resolves the peer's own fleet-synced owner destination, so a
|
|
484
|
-
// non-owner channel sink with an explicit recipient stays local.
|
|
485
|
-
if (owner) {
|
|
486
|
-
const forwarded = await forwardOwnerNotifyToPeer(sink.text ?? '', resolved.envelope.channel, meta);
|
|
487
|
-
if (forwarded?.ok)
|
|
488
|
-
return { name, ok: true };
|
|
489
|
-
}
|
|
490
503
|
return { name, ok: false, error: result.error };
|
|
491
504
|
}
|
|
492
505
|
/**
|
package/dist/lib/humans.d.ts
CHANGED
|
@@ -20,6 +20,15 @@ export declare function getOwnerNotifyFromHumans(): {
|
|
|
20
20
|
channel: string;
|
|
21
21
|
to: string;
|
|
22
22
|
} | null;
|
|
23
|
+
/**
|
|
24
|
+
* Return every addressable owner destination selected by the normal-severity
|
|
25
|
+
* policy, in policy order. A config without a policy keeps the historical
|
|
26
|
+
* single-channel behavior by selecting the first addressable channel.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getOwnerNotifyDestinationsFromHumans(): Array<{
|
|
29
|
+
channel: string;
|
|
30
|
+
to: string;
|
|
31
|
+
}>;
|
|
23
32
|
/**
|
|
24
33
|
* Read the owner block from humans.yaml. Returns null if missing.
|
|
25
34
|
*/
|
package/dist/lib/humans.js
CHANGED
|
@@ -51,19 +51,40 @@ export function writeHumans(config) {
|
|
|
51
51
|
* policy is declared, the first addressable channel is the default.
|
|
52
52
|
*/
|
|
53
53
|
export function getOwnerNotifyFromHumans() {
|
|
54
|
+
return getOwnerNotifyDestinationsFromHumans()[0] ?? null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Return every addressable owner destination selected by the normal-severity
|
|
58
|
+
* policy, in policy order. A config without a policy keeps the historical
|
|
59
|
+
* single-channel behavior by selecting the first addressable channel.
|
|
60
|
+
*/
|
|
61
|
+
export function getOwnerNotifyDestinationsFromHumans() {
|
|
54
62
|
const owner = readHumans()?.owner;
|
|
55
63
|
const channels = owner?.channels ?? [];
|
|
56
64
|
const preferredIds = owner?.policy?.normal ?? [];
|
|
57
|
-
const
|
|
58
|
-
.map((id) => channels.find((entry) => entry.id === id))
|
|
59
|
-
.find((entry) => entry
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
const selected = preferredIds.length > 0
|
|
66
|
+
? preferredIds.map((id) => channels.find((entry) => entry.id === id))
|
|
67
|
+
: [channels.find((entry) => entry.to)];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const destinations = selected
|
|
70
|
+
.filter((entry) => Boolean(entry?.id && entry.to))
|
|
71
|
+
.map((entry) => ({ channel: entry.id, to: entry.to }))
|
|
72
|
+
.filter((entry) => {
|
|
73
|
+
const key = `${entry.channel}\0${entry.to}`;
|
|
74
|
+
if (seen.has(key))
|
|
75
|
+
return false;
|
|
76
|
+
seen.add(key);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
if (destinations.length > 0)
|
|
80
|
+
return destinations;
|
|
81
|
+
const firstAddressable = channels.find((entry) => entry.id && entry.to);
|
|
82
|
+
if (firstAddressable?.to)
|
|
83
|
+
return [{ channel: firstAddressable.id, to: firstAddressable.to }];
|
|
63
84
|
const migrated = owner?.notify;
|
|
64
85
|
if (migrated?.channel && migrated.to)
|
|
65
|
-
return migrated;
|
|
66
|
-
return
|
|
86
|
+
return [migrated];
|
|
87
|
+
return [];
|
|
67
88
|
}
|
|
68
89
|
/**
|
|
69
90
|
* Read the owner block from humans.yaml. Returns null if missing.
|
package/dist/lib/notify.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ export interface OwnerNotifyOptions {
|
|
|
25
25
|
target?: string;
|
|
26
26
|
/** Resolve + build the delivery but do not actually send. */
|
|
27
27
|
dryRun?: boolean;
|
|
28
|
+
thread?: string;
|
|
29
|
+
attachments?: string[];
|
|
30
|
+
from?: string;
|
|
28
31
|
}
|
|
29
32
|
export interface NotifyResult {
|
|
30
33
|
ok: boolean;
|
package/dist/lib/notify.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readMeta } from './state.js';
|
|
2
|
-
import {
|
|
2
|
+
import { getOwnerNotifyDestinationsFromHumans } from './humans.js';
|
|
3
3
|
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
4
4
|
import { lookupTransport } from './channels/resolve.js';
|
|
5
5
|
import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
|
|
@@ -49,33 +49,75 @@ export function buildOpenClawNotifyArgs(text, opts) {
|
|
|
49
49
|
*/
|
|
50
50
|
export async function sendToOwner(text, options = {}) {
|
|
51
51
|
const meta = options.meta ?? readMeta();
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
52
|
+
const canonical = getOwnerNotifyDestinationsFromHumans();
|
|
53
|
+
const legacy = meta.notify?.owner ? [meta.notify.owner] : [];
|
|
54
|
+
const configured = canonical.length > 0 ? canonical : legacy;
|
|
55
|
+
const destinations = options.target
|
|
56
|
+
? [{ channel: options.channel ?? configured[0]?.channel, to: options.target }]
|
|
57
|
+
: options.channel
|
|
58
|
+
? [configured.find((dest) => dest.channel === options.channel) ?? {
|
|
59
|
+
channel: options.channel,
|
|
60
|
+
to: configured[0]?.to,
|
|
61
|
+
}]
|
|
62
|
+
: configured;
|
|
63
|
+
const addressable = destinations.filter((dest) => Boolean(dest.channel && dest.to));
|
|
64
|
+
if (addressable.length === 0) {
|
|
56
65
|
return {
|
|
57
66
|
ok: false,
|
|
58
|
-
channel: channel ?? 'unknown',
|
|
59
|
-
id: target ?? '',
|
|
67
|
+
channel: options.channel ?? 'unknown',
|
|
68
|
+
id: options.target ?? '',
|
|
60
69
|
error: 'No addressable owner channel configured in humans.yaml or legacy notify.owner',
|
|
61
70
|
};
|
|
62
71
|
}
|
|
63
72
|
registerBuiltinProviders();
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
const deliveries = [];
|
|
74
|
+
for (const { channel, to: target } of addressable) {
|
|
75
|
+
const { provider, error } = lookupTransport(channel, meta);
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = provider
|
|
79
|
+
? await provider.send(text, {
|
|
80
|
+
target,
|
|
81
|
+
ownerScoped: options.target === undefined,
|
|
82
|
+
dryRun: options.dryRun,
|
|
83
|
+
thread: options.thread,
|
|
84
|
+
attachments: options.attachments,
|
|
85
|
+
from: options.from,
|
|
86
|
+
})
|
|
87
|
+
: { ok: false, channel, id: target, error };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
result = { ok: false, channel, id: target, error: err.message };
|
|
91
|
+
}
|
|
92
|
+
// A dry-run never delivers, and an override target is an explicit recipient
|
|
93
|
+
// (not the fleet-wide owner) — neither should hop to a peer.
|
|
94
|
+
if (!result.ok && !options.dryRun && options.target === undefined) {
|
|
95
|
+
if (options.attachments?.length) {
|
|
96
|
+
result = {
|
|
97
|
+
...result,
|
|
98
|
+
error: `${result.error ?? 'local delivery failed'}; owner attachments cannot be forwarded to another device`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
result = await forwardOwnerNotifyToPeer(text, channel, target, meta, {
|
|
103
|
+
envelope: { thread: options.thread, from: options.from },
|
|
104
|
+
}) ?? result;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
deliveries.push(result);
|
|
67
108
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
if (deliveries.length === 1)
|
|
110
|
+
return deliveries[0];
|
|
111
|
+
const failures = deliveries.filter((result) => !result.ok);
|
|
112
|
+
return {
|
|
113
|
+
ok: deliveries.some((result) => result.ok),
|
|
114
|
+
channel: 'owner',
|
|
115
|
+
id: deliveries.map((result) => `${result.channel}:${result.id}`).join(','),
|
|
116
|
+
...(failures.length > 0
|
|
117
|
+
? { error: failures.map((result) => `${result.channel}: ${result.error ?? 'failed'}`).join('; ') }
|
|
118
|
+
: {}),
|
|
119
|
+
deliveries,
|
|
120
|
+
};
|
|
79
121
|
}
|
|
80
122
|
export async function notifyUrgentBlock(block, options = {}) {
|
|
81
123
|
if (block.notifiedAt) {
|
|
@@ -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",
|