Skip to content

Commit e812399

Browse files
MajorTalclaude
andauthored
feat(sdk,cli,mcp): agent-DX blob CDN diagnostics (v1.45) (#117)
Coordinated public-repo half of the v1.33 agent-DX blob CDN release. All additive — no existing API surface broken. SDK (sdk/src/namespaces/blobs.{ts,types.ts}): - Widen blobs.put return into AssetRef: keeps the snake_case fields for back-compat AND adds size, contentSha256, contentType, immutableUrl, etag, sri, contentDigest, cacheKind, cdn:{version,invalidationId,invalidationStatus,ready,hint}. - buildAssetRef derives etag/sri/contentDigest locally from the SHA so the SDK surface is stable across gateway versions. - New helpers: client.blobs.diagnoseUrl(projectId, url) and client.blobs.waitFresh(projectId, {url, sha256, timeoutMs?}). Signed slightly differently from the spec scenarios (added projectId first arg) for consistency with put/get/ls. - 8 new tests in blobs.test.ts (22 total). CLI (cli/lib/blob.mjs, cli/lib/cdn.mjs, cli/cli.mjs, openclaw/scripts/cdn.mjs): - run402 blob diagnose <url> — exit 0 on SHA match, 1 otherwise (shell-loop friendly). - run402 cdn wait-fresh <url> --sha <hex> [--timeout <secs>] — polls until fresh. - Both --help docs include exit-code semantics and the vantage caveat. MCP tools (src/tools/blob-{diagnose,wait-fresh}.ts, src/index.ts): - diagnose_public_url + wait_for_cdn_freshness with explicit agent guidance ('mutable URLs only'). - wait_for_cdn_freshness returns isError=true on timeout so agents branch into the immutableUrl fallback. Docs (SKILL.md, cli/llms-cli.txt): - 'Prefer immutableUrl in generated HTML/CSS/JS' guidance with reasoning (read-after-write correctness, integrity via SRI). - New tool sections for diagnose_public_url + wait_for_cdn_freshness. Notes: - sync.test.ts updated for the new SURFACE rows + cdn namespace; the llms.txt-alignment assertion fails locally until the private-repo PR merges (it reads ~/Developer/run402-private/site/llms.txt). CI in the public repo skips the suite when the path is absent. - Version is already 1.45.0 (chore: bump in 143f408). After this PR merges, run npm publish in coordination with the v1.33 backend cutover. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f7ef2f commit e812399

13 files changed

Lines changed: 980 additions & 14 deletions

File tree

SKILL.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,14 @@ Upload a blob (any size, up to 5 TiB) to project storage via direct-to-S3 presig
109109
- `immutable` (optional, default: `false`) — If true with `sha256`, also produces a content-addressed URL that gets `Cache-Control: immutable`.
110110
- `sha256` (optional) — Required when `immutable: true`. Client-asserted hash; gateway verifies if S3 returns one.
111111

112-
**Returns:** `{ key, size_bytes, sha256, url, immutable_url? }`. `url` is the CDN URL (on `pr-<public_id>.run402.com`); `immutable_url` is the content-addressed variant when applicable.
112+
**Returns:** an `AssetRef`: `{ key, size_bytes, sha256, visibility, url, immutable_url, size, contentSha256, contentType, immutableUrl, etag, sri, contentDigest, cacheKind, cdn: { version, invalidationId, invalidationStatus, ready, hint } }`. The legacy snake_case fields (`size_bytes`, `sha256`, `immutable_url`) are kept for back-compat; the camelCase fields are the v1.45 agent-DX surface.
113+
114+
**Prefer `immutableUrl` in generated HTML/CSS/JS code.** Reasoning:
115+
- Read-after-write correctness: the immutable URL is bound to the SHA at upload time and was never previously cached. The mutable `url` is "eventually fresh" — invalidation is asynchronous, so a `<script>` tag pointing at it may load the OLD version for seconds-to-minutes after a re-upload.
116+
- Built-in integrity: pair `immutableUrl` with `integrity={sri}` on `<script>` and `<link>` tags so browsers verify the hash before executing.
117+
- No follow-up calls needed: an agent emitting `<script src={immutableUrl} integrity={sri}>` doesn't need to call `wait_for_cdn_freshness` afterwards. Use the mutable `url` only when the URL must remain stable across re-uploads.
118+
119+
If you must use the mutable `url` (e.g. you're updating an `<img>` referenced by an external system that won't accept a new URL), call `wait_for_cdn_freshness` before publishing the change. **Mutable URLs only**; never call wait_for_cdn_freshness on `immutableUrl`.
113120

114121
Supersedes `upload_file` (deprecated).
115122

@@ -157,6 +164,34 @@ Generate a time-boxed S3 presigned GET URL for a private blob. Use this to share
157164

158165
**Returns:** `{ url, expires_at }`.
159166

167+
### diagnose_public_url
168+
169+
Returns the live CDN state for a public blob URL — expected vs observed SHA, CloudFront cache headers, recent invalidation status, vantage. Use this when a deployed asset shows the wrong version or you suspect cache staleness.
170+
171+
**Parameters:**
172+
- `project_id` (required) — Project ID that owns the URL
173+
- `url` (required) — Full blob URL (e.g. `https://app.run402.com/_blob/avatar.png`)
174+
175+
**Returns:** the diagnose envelope including `expectedSha256`, `observedSha256`, `cache.{xCache,ageSeconds,cacheKind}`, `invalidation.{id,status}`, `vantage: "gateway-us-east-1"`, `probeMethod: "GET_RANGE_0_0"`, `probeMayHaveWarmedCache: true`, and a human-readable `hint` with actionable next steps.
176+
177+
**Vantage caveat:** The probe is single-region (us-east-1). Other CloudFront PoPs may serve different cached states. The `probeMayHaveWarmedCache: true` field warns that the probe itself populates the cache for that PoP, so subsequent reads from elsewhere may differ.
178+
179+
**Errors:**
180+
- 403 — URL belongs to a different project than your apikey
181+
- 400 — URL is not on `*.run402.com` AND not on one of your active custom domains (SSRF guard)
182+
183+
### wait_for_cdn_freshness
184+
185+
Polls the CDN until a **mutable** blob URL serves the expected SHA-256, or the timeout elapses. **For mutable URLs only** — for immutable URLs (the `immutableUrl` field returned by `blob_put`), no waiting is needed; they're bound at upload time and never previously cached.
186+
187+
**Parameters:**
188+
- `project_id` (required) — Project ID that owns the URL
189+
- `url` (required) — Mutable blob URL to poll
190+
- `sha256` (required) — Expected hex SHA-256
191+
- `timeout_ms` (optional, default 60_000, max 600_000) — Max wait in milliseconds
192+
193+
**Returns:** `{ fresh, observedSha256, attempts, elapsedMs, vantage }`. The tool returns `isError=true` on timeout so an agent can branch into a fallback (typically: switch to the immutableUrl, which is always immediately correct).
194+
160195
### upload_file (deprecated)
161196

162197
Legacy file upload. **Deprecated — sunset 2026-06-01.** Use `blob_put` instead.

cli/cli.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ Commands:
2828
deploy Deploy a full-stack app or static site (requires active tier)
2929
functions Manage serverless functions (deploy, invoke, logs, list, delete)
3030
secrets Manage project secrets (set, list, delete)
31-
blob Direct-to-S3 blob storage (put, get, ls, rm, sign) — up to 5 TiB
31+
blob Direct-to-S3 blob storage (put, get, ls, rm, sign, diagnose) — up to 5 TiB
3232
storage Legacy file storage (deprecated — sunset 2026-06-01, use 'blob')
3333
sites Deploy static sites
34+
cdn CloudFront CDN diagnostics (wait-fresh) for public blob URLs
3435
subdomains Manage custom subdomains (claim, list, delete)
3536
domains Manage custom domains (add, list, status, delete)
3637
apps Browse and manage the app marketplace
@@ -129,6 +130,11 @@ switch (cmd) {
129130
await run(sub, rest);
130131
break;
131132
}
133+
case "cdn": {
134+
const { run } = await import("./lib/cdn.mjs");
135+
await run(sub, rest);
136+
break;
137+
}
132138
case "sites": {
133139
const { run } = await import("./lib/sites.mjs");
134140
await run(sub, rest);

cli/lib/blob.mjs

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Usage:
4646
run402 blob ls [options]
4747
run402 blob rm <key> [options]
4848
run402 blob sign <key> [options]
49+
run402 blob diagnose <url> [options]
4950
5051
Options:
5152
--project <id> Project ID (defaults to active project from 'run402 projects use')
@@ -150,6 +151,32 @@ Options:
150151
151152
Examples:
152153
run402 blob sign reports/2025-q4.pdf --project abc123 --ttl 600
154+
`,
155+
diagnose: `run402 blob diagnose — Inspect the live CDN state for a public blob URL
156+
157+
Usage:
158+
run402 blob diagnose <url> [options]
159+
160+
Arguments:
161+
<url> Full blob URL (e.g. https://app.run402.com/_blob/avatar.png)
162+
163+
Options:
164+
--project <id> Project ID (defaults to active project)
165+
166+
Output:
167+
- Prints the JSON envelope on stdout (parseable by agent shell loops).
168+
- Vantage caveat ("# probed once from gateway-us-east-1; not a global view")
169+
on stderr — visible to TTY operators, ignored by piped consumers.
170+
171+
Exit codes:
172+
0 observed SHA matches the gateway's expected SHA
173+
1 observed SHA does not match (or probe returned no SHA)
174+
175+
Agent loop pattern:
176+
until run402 blob diagnose <url>; do sleep 1; done
177+
178+
Examples:
179+
run402 blob diagnose https://app.run402.com/_blob/avatar.png
153180
`,
154181
};
155182

@@ -445,6 +472,33 @@ async function rm(projectId, argv) {
445472
// sign
446473
// ---------------------------------------------------------------------------
447474

475+
async function diagnose(projectId, argv) {
476+
const opts = parseArgs(argv);
477+
opts.project = opts.project || projectId;
478+
const resolvedId = resolveProjectId(opts.project);
479+
if (opts.positional.length === 0) die("URL required");
480+
const url = opts.positional[0];
481+
482+
try {
483+
const env = await getSdk().blobs.diagnoseUrl(resolvedId, url);
484+
// Always print the JSON envelope for agent consumption (parseable).
485+
console.log(JSON.stringify(env, null, 2));
486+
// Vantage caveat to stderr so a TTY operator sees it; agent shell loops
487+
// that pipe stdout into another tool aren't affected.
488+
process.stderr.write(
489+
`\n# probed once from ${env.vantage}; not a global view\n`,
490+
);
491+
// Exit code: 0 if observed === expected, 1 otherwise. Lets agents
492+
// shell-script `until run402 blob diagnose <url>; do sleep 1; done`.
493+
if (env.observedSha256 && env.observedSha256 === env.expectedSha256) {
494+
process.exit(0);
495+
}
496+
process.exit(1);
497+
} catch (err) {
498+
reportSdkError(err);
499+
}
500+
}
501+
448502
async function sign(projectId, argv) {
449503
const opts = parseArgs(argv);
450504
opts.project = opts.project || projectId;
@@ -518,11 +572,12 @@ export async function run(sub, args) {
518572
}
519573
const defaultProject = process.env.RUN402_PROJECT ?? null;
520574
switch (sub) {
521-
case "put": await put(defaultProject, args); break;
522-
case "get": await get(defaultProject, args); break;
523-
case "ls": await ls(defaultProject, args); break;
524-
case "rm": await rm(defaultProject, args); break;
525-
case "sign": await sign(defaultProject, args); break;
575+
case "put": await put(defaultProject, args); break;
576+
case "get": await get(defaultProject, args); break;
577+
case "ls": await ls(defaultProject, args); break;
578+
case "rm": await rm(defaultProject, args); break;
579+
case "sign": await sign(defaultProject, args); break;
580+
case "diagnose": await diagnose(defaultProject, args); break;
526581
default:
527582
console.error(`Unknown subcommand: ${sub}`);
528583
console.log(HELP);

cli/lib/cdn.mjs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* run402 cdn — CloudFront CDN diagnostics for public blob URLs.
3+
*
4+
* Usage:
5+
* run402 cdn wait-fresh <url> --sha <sha256> [--timeout <seconds>] [--project <id>]
6+
*
7+
* Wraps the SDK's `client.blobs.waitFresh(...)`. Polls the gateway diagnose
8+
* endpoint until the URL serves the expected SHA, then exits 0. On timeout,
9+
* exits 1 (agent shell loops can chain a fallback action).
10+
*
11+
* **Mutable URLs only.** For immutable URLs (the `immutableUrl` field
12+
* returned by `run402 blob put --immutable`), no waiting is needed — they
13+
* are bound to a SHA at upload time and never previously cached.
14+
*/
15+
16+
import { resolveProjectId } from "./config.mjs";
17+
import { getSdk } from "./sdk.mjs";
18+
import { reportSdkError } from "./sdk-errors.mjs";
19+
20+
const HELP = `run402 cdn — CloudFront CDN diagnostics for public blob URLs
21+
22+
Usage:
23+
run402 cdn wait-fresh <url> --sha <sha256> [options]
24+
25+
Subcommands:
26+
wait-fresh Poll the CDN until a mutable URL serves the expected SHA-256
27+
28+
Examples:
29+
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha abc...
30+
31+
For details: run402 cdn wait-fresh --help
32+
`;
33+
34+
const SUB_HELP = {
35+
"wait-fresh": `run402 cdn wait-fresh — Poll the CDN for an expected SHA on a mutable URL
36+
37+
Usage:
38+
run402 cdn wait-fresh <url> --sha <sha256> [options]
39+
40+
Arguments:
41+
<url> Full mutable blob URL to poll (e.g.
42+
https://app.run402.com/_blob/avatar.png)
43+
44+
Options:
45+
--sha <hex> Expected hex SHA-256. Required.
46+
--timeout <secs> Max wait in seconds. Default 60.
47+
--project <id> Project ID (defaults to active project)
48+
49+
Output:
50+
Prints a JSON result on stdout when polling ends:
51+
{ "fresh": true|false, "observedSha256": "...", "attempts": N,
52+
"elapsedMs": ..., "vantage": "gateway-us-east-1" }
53+
54+
Exit codes:
55+
0 the URL served the expected SHA before the timeout
56+
1 the URL did NOT match within the timeout (or the project resolution failed)
57+
58+
Notes:
59+
- For IMMUTABLE URLs (returned as 'immutableUrl' from 'run402 blob put
60+
--immutable'), no waiting is needed. Use this command only for the
61+
mutable 'url' field, after a re-upload to an existing public key.
62+
- The probe is single-vantage (us-east-1). Other CloudFront PoPs may
63+
serve different cached states until invalidation propagates.
64+
65+
Examples:
66+
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78...
67+
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --timeout 120
68+
`,
69+
};
70+
71+
function die(msg, code = 1) {
72+
console.error(JSON.stringify({ status: "error", message: msg }));
73+
process.exit(code);
74+
}
75+
76+
function parseArgs(args) {
77+
const opts = { positional: [] };
78+
for (let i = 0; i < args.length; i++) {
79+
const a = args[i];
80+
if (a === "--sha") opts.sha = args[++i];
81+
else if (a === "--timeout") opts.timeout = Number(args[++i]);
82+
else if (a === "--project") opts.project = args[++i];
83+
else if (a.startsWith("--")) die(`Unknown option: ${a}`);
84+
else opts.positional.push(a);
85+
}
86+
return opts;
87+
}
88+
89+
async function waitFresh(projectId, argv) {
90+
const opts = parseArgs(argv);
91+
opts.project = opts.project || projectId;
92+
const resolvedId = resolveProjectId(opts.project);
93+
if (opts.positional.length === 0) die("URL required");
94+
const url = opts.positional[0];
95+
if (!opts.sha) die("--sha is required");
96+
97+
const timeoutMs = (opts.timeout ?? 60) * 1000;
98+
try {
99+
const result = await getSdk().blobs.waitFresh(resolvedId, {
100+
url,
101+
sha256: opts.sha.toLowerCase(),
102+
timeoutMs,
103+
});
104+
console.log(JSON.stringify(result, null, 2));
105+
process.exit(result.fresh ? 0 : 1);
106+
} catch (err) {
107+
reportSdkError(err);
108+
}
109+
}
110+
111+
export async function run(sub, args) {
112+
if (!sub || sub === "--help" || sub === "-h") {
113+
console.log(HELP);
114+
process.exit(0);
115+
}
116+
if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) {
117+
console.log(SUB_HELP[sub] || HELP);
118+
process.exit(0);
119+
}
120+
const defaultProject = process.env.RUN402_PROJECT ?? null;
121+
switch (sub) {
122+
case "wait-fresh":
123+
await waitFresh(defaultProject, args);
124+
break;
125+
default:
126+
console.error(`Unknown subcommand: ${sub}`);
127+
console.log(HELP);
128+
process.exit(1);
129+
}
130+
}

cli/llms-cli.txt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@ Direct-to-S3 blob storage. Works for any size from 1 byte up to 5 TiB. No bucket
393393
- `run402 blob ls [--project <id>] [--prefix <p>] [--limit <n>]`
394394
- `run402 blob rm <key> [--project <id>]`
395395
- `run402 blob sign <key> [--project <id>] [--ttl <seconds>]`
396+
- `run402 blob diagnose <url> [--project <id>]` — inspect live CDN state for a public URL
397+
- `run402 cdn wait-fresh <url> --sha <hex> [--timeout <secs>] [--project <id>]` — poll a mutable URL until it serves the expected SHA-256
396398

397399
Examples:
398400

@@ -402,16 +404,27 @@ run402 blob put ./dist/**/*.png --project abc123 --key assets/
402404
run402 blob put huge.bin --project abc123 --immutable
403405
run402 blob get images/logo.png --output /tmp/logo.png --project abc123
404406
run402 blob ls --project abc123 --prefix images/
407+
run402 blob diagnose https://app.run402.com/_blob/avatar.png --project abc123
408+
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --timeout 120
405409
```
406410

407-
`put` response (public blobs) includes:
408-
- `url` — stable URL on the project's CDN subdomain: `https://pr-<public_id>.run402.com/_blob/<key>`, also `https://<claimed-subdomain>.run402.com/_blob/<key>` and any mapped custom domain. Cached at CloudFront edge.
409-
- `immutable_url` (only when `--immutable`) — content-hashed URL: `https://<host>/_blob/<key-without-ext>-<8hex>.<ext>`. Resolves to the same S3 object but safe to cache eternally (`Cache-Control: public, max-age=31536000, immutable`).
411+
`put` response (an `AssetRef`) includes both legacy snake_case and v1.45 camelCase fields:
412+
- `url` / (no camelCase alias) — stable mutable URL: `https://pr-<public_id>.run402.com/_blob/<key>`, also any claimed subdomain or mapped custom domain. Cached at CloudFront edge; invalidation is asynchronous on overwrite.
413+
- `immutable_url` / `immutableUrl` (only when `--immutable`) — content-hashed URL: `https://<host>/_blob/<key-without-ext>-<8hex>.<ext>`. Bound to a SHA at upload time; never previously cached. **Prefer this in generated HTML/CSS/JS code** — it doesn't need waiting and pairs with `sri` for browser SRI verification.
414+
- `etag` — strong `"sha256-<hex>"` ETag (when `--immutable`).
415+
- `sri` — `sha256-<base64>` for `<script integrity={sri}>` and `<link integrity={sri}>`.
416+
- `contentDigest` — RFC 9530 `sha-256=:<base64>:` for HTTP integrity.
417+
- `cacheKind` — `"immutable" | "mutable" | "private"`.
418+
- `cdn.{version,invalidationId,invalidationStatus,ready,hint}` — CloudFront invalidation envelope; `cdn.ready === true` for immutable uploads.
419+
420+
**Agent guidance.** When emitting HTML/CSS/JS that links a just-uploaded asset, use `immutableUrl` + `integrity={sri}`. Read-after-write is correct and you don't need a follow-up `cdn wait-fresh` poll. Use the mutable `url` only when the URL must remain stable across re-uploads; in that case, `run402 cdn wait-fresh <url> --sha <new-sha>` blocks until the CDN serves the new SHA.
410421

411422
Resume: state is persisted to `~/.run402/uploads/<upload_id>.json`. Ctrl-C mid-upload and re-run the same `blob put` command — it picks up where it left off. Pass `--no-resume` to start fresh.
412423

413424
Private blobs (`--private`): no CDN URL returned; read via authenticated gateway path `GET /storage/v1/blob/<key>` with apikey, or via `run402 blob sign` for time-boxed external sharing.
414425

426+
`diagnose` exit codes are shell-loop-friendly: `0` when the CDN serves the gateway's expected SHA, `1` otherwise. So `until run402 blob diagnose <url>; do sleep 1; done` blocks until the CDN catches up. Vantage: probes are single-region (us-east-1); the `# probed once from gateway-us-east-1; not a global view` line on stderr is the explicit caveat.
427+
415428
#### Uploading from edge functions
416429

417430
From within a deployed function, use the HTTP API directly:

openclaw/scripts/cdn.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { run } from "../../cli/lib/cdn.mjs";

0 commit comments

Comments
 (0)