Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,14 @@ Upload a blob (any size, up to 5 TiB) to project storage via direct-to-S3 presig
- `immutable` (optional, default: `false`) — If true with `sha256`, also produces a content-addressed URL that gets `Cache-Control: immutable`.
- `sha256` (optional) — Required when `immutable: true`. Client-asserted hash; gateway verifies if S3 returns one.

**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.
**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.

**Prefer `immutableUrl` in generated HTML/CSS/JS code.** Reasoning:
- 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.
- Built-in integrity: pair `immutableUrl` with `integrity={sri}` on `<script>` and `<link>` tags so browsers verify the hash before executing.
- 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.

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`.

Supersedes `upload_file` (deprecated).

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

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

### diagnose_public_url

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.

**Parameters:**
- `project_id` (required) — Project ID that owns the URL
- `url` (required) — Full blob URL (e.g. `https://app.run402.com/_blob/avatar.png`)

**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.

**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.

**Errors:**
- 403 — URL belongs to a different project than your apikey
- 400 — URL is not on `*.run402.com` AND not on one of your active custom domains (SSRF guard)

### wait_for_cdn_freshness

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.

**Parameters:**
- `project_id` (required) — Project ID that owns the URL
- `url` (required) — Mutable blob URL to poll
- `sha256` (required) — Expected hex SHA-256
- `timeout_ms` (optional, default 60_000, max 600_000) — Max wait in milliseconds

**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).

### upload_file (deprecated)

Legacy file upload. **Deprecated — sunset 2026-06-01.** Use `blob_put` instead.
Expand Down
8 changes: 7 additions & 1 deletion cli/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ Commands:
deploy Deploy a full-stack app or static site (requires active tier)
functions Manage serverless functions (deploy, invoke, logs, list, delete)
secrets Manage project secrets (set, list, delete)
blob Direct-to-S3 blob storage (put, get, ls, rm, sign) — up to 5 TiB
blob Direct-to-S3 blob storage (put, get, ls, rm, sign, diagnose) — up to 5 TiB
storage Legacy file storage (deprecated — sunset 2026-06-01, use 'blob')
sites Deploy static sites
cdn CloudFront CDN diagnostics (wait-fresh) for public blob URLs
subdomains Manage custom subdomains (claim, list, delete)
domains Manage custom domains (add, list, status, delete)
apps Browse and manage the app marketplace
Expand Down Expand Up @@ -129,6 +130,11 @@ switch (cmd) {
await run(sub, rest);
break;
}
case "cdn": {
const { run } = await import("./lib/cdn.mjs");
await run(sub, rest);
break;
}
case "sites": {
const { run } = await import("./lib/sites.mjs");
await run(sub, rest);
Expand Down
65 changes: 60 additions & 5 deletions cli/lib/blob.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Usage:
run402 blob ls [options]
run402 blob rm <key> [options]
run402 blob sign <key> [options]
run402 blob diagnose <url> [options]

Options:
--project <id> Project ID (defaults to active project from 'run402 projects use')
Expand Down Expand Up @@ -150,6 +151,32 @@ Options:

Examples:
run402 blob sign reports/2025-q4.pdf --project abc123 --ttl 600
`,
diagnose: `run402 blob diagnose — Inspect the live CDN state for a public blob URL

Usage:
run402 blob diagnose <url> [options]

Arguments:
<url> Full blob URL (e.g. https://app.run402.com/_blob/avatar.png)

Options:
--project <id> Project ID (defaults to active project)

Output:
- Prints the JSON envelope on stdout (parseable by agent shell loops).
- Vantage caveat ("# probed once from gateway-us-east-1; not a global view")
on stderr — visible to TTY operators, ignored by piped consumers.

Exit codes:
0 observed SHA matches the gateway's expected SHA
1 observed SHA does not match (or probe returned no SHA)

Agent loop pattern:
until run402 blob diagnose <url>; do sleep 1; done

Examples:
run402 blob diagnose https://app.run402.com/_blob/avatar.png
`,
};

Expand Down Expand Up @@ -445,6 +472,33 @@ async function rm(projectId, argv) {
// sign
// ---------------------------------------------------------------------------

async function diagnose(projectId, argv) {
const opts = parseArgs(argv);
opts.project = opts.project || projectId;
const resolvedId = resolveProjectId(opts.project);
if (opts.positional.length === 0) die("URL required");
const url = opts.positional[0];

try {
const env = await getSdk().blobs.diagnoseUrl(resolvedId, url);
// Always print the JSON envelope for agent consumption (parseable).
console.log(JSON.stringify(env, null, 2));
// Vantage caveat to stderr so a TTY operator sees it; agent shell loops
// that pipe stdout into another tool aren't affected.
process.stderr.write(
`\n# probed once from ${env.vantage}; not a global view\n`,
);
// Exit code: 0 if observed === expected, 1 otherwise. Lets agents
// shell-script `until run402 blob diagnose <url>; do sleep 1; done`.
if (env.observedSha256 && env.observedSha256 === env.expectedSha256) {
process.exit(0);
}
process.exit(1);
} catch (err) {
reportSdkError(err);
}
}

async function sign(projectId, argv) {
const opts = parseArgs(argv);
opts.project = opts.project || projectId;
Expand Down Expand Up @@ -518,11 +572,12 @@ export async function run(sub, args) {
}
const defaultProject = process.env.RUN402_PROJECT ?? null;
switch (sub) {
case "put": await put(defaultProject, args); break;
case "get": await get(defaultProject, args); break;
case "ls": await ls(defaultProject, args); break;
case "rm": await rm(defaultProject, args); break;
case "sign": await sign(defaultProject, args); break;
case "put": await put(defaultProject, args); break;
case "get": await get(defaultProject, args); break;
case "ls": await ls(defaultProject, args); break;
case "rm": await rm(defaultProject, args); break;
case "sign": await sign(defaultProject, args); break;
case "diagnose": await diagnose(defaultProject, args); break;
default:
console.error(`Unknown subcommand: ${sub}`);
console.log(HELP);
Expand Down
130 changes: 130 additions & 0 deletions cli/lib/cdn.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* run402 cdn — CloudFront CDN diagnostics for public blob URLs.
*
* Usage:
* run402 cdn wait-fresh <url> --sha <sha256> [--timeout <seconds>] [--project <id>]
*
* Wraps the SDK's `client.blobs.waitFresh(...)`. Polls the gateway diagnose
* endpoint until the URL serves the expected SHA, then exits 0. On timeout,
* exits 1 (agent shell loops can chain a fallback action).
*
* **Mutable URLs only.** For immutable URLs (the `immutableUrl` field
* returned by `run402 blob put --immutable`), no waiting is needed — they
* are bound to a SHA at upload time and never previously cached.
*/

import { resolveProjectId } from "./config.mjs";
import { getSdk } from "./sdk.mjs";
import { reportSdkError } from "./sdk-errors.mjs";

const HELP = `run402 cdn — CloudFront CDN diagnostics for public blob URLs

Usage:
run402 cdn wait-fresh <url> --sha <sha256> [options]

Subcommands:
wait-fresh Poll the CDN until a mutable URL serves the expected SHA-256

Examples:
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha abc...

For details: run402 cdn wait-fresh --help
`;

const SUB_HELP = {
"wait-fresh": `run402 cdn wait-fresh — Poll the CDN for an expected SHA on a mutable URL

Usage:
run402 cdn wait-fresh <url> --sha <sha256> [options]

Arguments:
<url> Full mutable blob URL to poll (e.g.
https://app.run402.com/_blob/avatar.png)

Options:
--sha <hex> Expected hex SHA-256. Required.
--timeout <secs> Max wait in seconds. Default 60.
--project <id> Project ID (defaults to active project)

Output:
Prints a JSON result on stdout when polling ends:
{ "fresh": true|false, "observedSha256": "...", "attempts": N,
"elapsedMs": ..., "vantage": "gateway-us-east-1" }

Exit codes:
0 the URL served the expected SHA before the timeout
1 the URL did NOT match within the timeout (or the project resolution failed)

Notes:
- For IMMUTABLE URLs (returned as 'immutableUrl' from 'run402 blob put
--immutable'), no waiting is needed. Use this command only for the
mutable 'url' field, after a re-upload to an existing public key.
- The probe is single-vantage (us-east-1). Other CloudFront PoPs may
serve different cached states until invalidation propagates.

Examples:
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78...
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --timeout 120
`,
};

function die(msg, code = 1) {
console.error(JSON.stringify({ status: "error", message: msg }));
process.exit(code);
}

function parseArgs(args) {
const opts = { positional: [] };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--sha") opts.sha = args[++i];
else if (a === "--timeout") opts.timeout = Number(args[++i]);
else if (a === "--project") opts.project = args[++i];
else if (a.startsWith("--")) die(`Unknown option: ${a}`);
else opts.positional.push(a);
}
return opts;
}

async function waitFresh(projectId, argv) {
const opts = parseArgs(argv);
opts.project = opts.project || projectId;
const resolvedId = resolveProjectId(opts.project);
if (opts.positional.length === 0) die("URL required");
const url = opts.positional[0];
if (!opts.sha) die("--sha is required");

const timeoutMs = (opts.timeout ?? 60) * 1000;
try {
const result = await getSdk().blobs.waitFresh(resolvedId, {
url,
sha256: opts.sha.toLowerCase(),
timeoutMs,
});
console.log(JSON.stringify(result, null, 2));
process.exit(result.fresh ? 0 : 1);
} catch (err) {
reportSdkError(err);
}
}

export async function run(sub, args) {
if (!sub || sub === "--help" || sub === "-h") {
console.log(HELP);
process.exit(0);
}
if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) {
console.log(SUB_HELP[sub] || HELP);
process.exit(0);
}
const defaultProject = process.env.RUN402_PROJECT ?? null;
switch (sub) {
case "wait-fresh":
await waitFresh(defaultProject, args);
break;
default:
console.error(`Unknown subcommand: ${sub}`);
console.log(HELP);
process.exit(1);
}
}
19 changes: 16 additions & 3 deletions cli/llms-cli.txt
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ Direct-to-S3 blob storage. Works for any size from 1 byte up to 5 TiB. No bucket
- `run402 blob ls [--project <id>] [--prefix <p>] [--limit <n>]`
- `run402 blob rm <key> [--project <id>]`
- `run402 blob sign <key> [--project <id>] [--ttl <seconds>]`
- `run402 blob diagnose <url> [--project <id>]` — inspect live CDN state for a public URL
- `run402 cdn wait-fresh <url> --sha <hex> [--timeout <secs>] [--project <id>]` — poll a mutable URL until it serves the expected SHA-256

Examples:

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

`put` response (public blobs) includes:
- `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.
- `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`).
`put` response (an `AssetRef`) includes both legacy snake_case and v1.45 camelCase fields:
- `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.
- `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.
- `etag` — strong `"sha256-<hex>"` ETag (when `--immutable`).
- `sri` — `sha256-<base64>` for `<script integrity={sri}>` and `<link integrity={sri}>`.
- `contentDigest` — RFC 9530 `sha-256=:<base64>:` for HTTP integrity.
- `cacheKind` — `"immutable" | "mutable" | "private"`.
- `cdn.{version,invalidationId,invalidationStatus,ready,hint}` — CloudFront invalidation envelope; `cdn.ready === true` for immutable uploads.

**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.

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.

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.

`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.

#### Uploading from edge functions

From within a deployed function, use the HTTP API directly:
Expand Down
1 change: 1 addition & 0 deletions openclaw/scripts/cdn.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { run } from "../../cli/lib/cdn.mjs";
Loading
Loading