Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Artifact Worker Base URL
# Used by the debugger to load large programs from the artifact worker
VITE_ARTIFACTS_BASE_URL=https://pvm-artifacts.tomusdrw-cloudflare.workers.dev
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ dist-ssr
/playwright-report/
/blob-report/
/playwright/.cache/

# Environment files
.env
!.env.example
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ There are few ways how you can add your own PVM to execute the code.

Details about the API requirements can be found in [#81](https://github.com/FluffyLabs/pvm-debugger/issues/81)

## Large Program Links (Artifacts)

For large binaries (too large for `?program=0x...` URL payloads), use the Cloudflare artifact worker in
[`workers/artifacts-worker`](./workers/artifacts-worker).

The debugger can load `?artifact=<id-or-url>` from `/load`. Set up your environment:

```bash
cp .env.example .env
```

This configures `VITE_ARTIFACTS_BASE_URL=https://pvm-artifacts.tomusdrw-cloudflare.workers.dev`.

Programmatic upload helper:

```bash
npm run artifact:upload -- \
--file ./path/to/program.bin \
--worker https://pvm-artifacts.tomusdrw-cloudflare.workers.dev \
--debugger http://localhost:5173
```

This prints the returned `artifactId` and a ready-to-open debugger link:

```text
http://localhost:5173/#/load?artifact=<artifactId>
```

Upload and open the debugger automatically:

```bash
npm run artifact:upload:open -- \
--file ./path/to/program.bin \
--worker https://pvm-artifacts.tomusdrw-cloudflare.workers.dev \
--debugger http://localhost:5173
```

## Development

### Requirements
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:install": "playwright install --with-deps",
"artifact:upload": "node scripts/upload-artifact.mjs",
"artifact:upload:open": "node scripts/upload-artifact.mjs --open",
"artifact:worker:dev": "npx wrangler dev --config workers/artifacts-worker/wrangler.toml",
"artifact:worker:deploy": "npx wrangler deploy --config workers/artifacts-worker/wrangler.toml",
"prepare": "husky"
},
"dependencies": {
Expand Down
127 changes: 127 additions & 0 deletions scripts/upload-artifact.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

function printUsage() {
console.error(
"Usage: node scripts/upload-artifact.mjs --file <path> --worker <base-url> [--ttl <seconds>] [--debugger <base-url>] [--open]",
);
}

function parseArgs(argv) {
const args = {};

for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
const value = argv[index + 1];

if (!key.startsWith("--")) {
continue;
}

if (!value || value.startsWith("--")) {
args[key.slice(2)] = "true";
continue;
}

args[key.slice(2)] = value;
index += 1;
Comment on lines +23 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don't coerce missing values to "true" for non-boolean flags.

This branch currently turns --file, --worker, --ttl, and --debugger into the string "true" when the next token is missing or another flag. The CLI then fails much later with misleading errors instead of stopping at argument parsing.

Proposed fix
 function parseArgs(argv) {
   const args = {};
+  const booleanFlags = new Set(["open"]);
 
   for (let index = 0; index < argv.length; index += 1) {
     const key = argv[index];
     const value = argv[index + 1];
+    const name = key.slice(2);
 
     if (!key.startsWith("--")) {
       continue;
     }
 
     if (!value || value.startsWith("--")) {
-      args[key.slice(2)] = "true";
+      if (!booleanFlags.has(name)) {
+        throw new Error(`Missing value for --${name}`);
+      }
+      args[name] = "true";
       continue;
     }
 
-    args[key.slice(2)] = value;
+    args[name] = value;
     index += 1;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!value || value.startsWith("--")) {
args[key.slice(2)] = "true";
continue;
}
args[key.slice(2)] = value;
index += 1;
function parseArgs(argv) {
const args = {};
const booleanFlags = new Set(["open"]);
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
const value = argv[index + 1];
const name = key.slice(2);
if (!key.startsWith("--")) {
continue;
}
if (!value || value.startsWith("--")) {
if (!booleanFlags.has(name)) {
throw new Error(`Missing value for --${name}`);
}
args[name] = "true";
continue;
}
args[name] = value;
index += 1;
}
return args;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/upload-artifact.mjs` around lines 23 - 29, The parser is incorrectly
coercing missing or next-flag tokens into the string "true" for non-boolean
flags; update the logic around args, key.slice(2), value and index so only known
boolean flags are set to "true" when value is missing, and for other flags
throw/emit a clear parse error and stop (or return non-zero) instead of
assigning "true". Concretely, maintain a set/array of boolean flags (e.g.,
["help","debugger"] or whichever apply), check if value is missing or
startsWith("--"): if the flag name (key.slice(2)) is in that boolean set then
set args[...] = "true" and continue; otherwise raise/console.error a descriptive
message about the missing value for that flag and exit/fail the parse; ensure
index is only incremented when you consume a value.

}

return args;
}

function normalizeBaseUrl(url) {
return url.replace(/\/+$/, "");
}

function shouldOpenBrowser(value) {
return value === "true" || value === "1" || value === "yes";
}

function openInBrowser(url) {
const launchCommands =
process.platform === "darwin"
? [["open", [url]]]
: process.platform === "win32"
? [["cmd", ["/c", "start", "", url]]]
: [["xdg-open", [url]]];

for (const [command, args] of launchCommands) {
const result = spawnSync(command, args, { stdio: "ignore" });
if (!result.error && result.status === 0) {
return;
}
}

throw new Error("Could not open browser automatically on this platform.");
}

async function main() {
const args = parseArgs(process.argv.slice(2));
const filePath = args.file;
const workerBase = args.worker;
const ttl = args.ttl;
const debuggerBase = args.debugger;
const open = shouldOpenBrowser(args.open);

if (!filePath || !workerBase) {
printUsage();
process.exit(1);
}

if (open && !debuggerBase) {
console.error("--open requires --debugger <base-url>.");
process.exit(1);
}

const file = await readFile(filePath);
const endpoint = new URL(`${normalizeBaseUrl(workerBase)}/artifacts`);

if (ttl) {
endpoint.searchParams.set("ttl", ttl);
}

const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"x-upload-file-name": basename(filePath),
},
body: file,
});
Comment on lines +86 to +93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bound the upload request with a timeout.

This POST can currently hang forever on DNS/TLS/network stalls. Please add an abort timeout so the CLI fails predictably when the worker is unreachable.

Proposed fix
   const response = await fetch(endpoint, {
     method: "POST",
     headers: {
       "content-type": "application/octet-stream",
       "x-upload-file-name": basename(filePath),
     },
     body: file,
+    signal: AbortSignal.timeout(30_000),
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"x-upload-file-name": basename(filePath),
},
body: file,
});
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"x-upload-file-name": basename(filePath),
},
body: file,
signal: AbortSignal.timeout(30_000),
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/upload-artifact.mjs` around lines 86 - 93, The fetch POST to
`endpoint` that produces `response` can hang; wrap the fetch call with an
AbortController and a setTimeout (e.g., 30s or configurable) that calls
controller.abort() to bound the request, pass controller.signal into the fetch
options, and clear the timeout once fetch resolves; update error handling around
the `response` check to treat an aborted fetch as a predictable timeout error
(use the same exit/failure path you already use for network errors) and
reference `endpoint`, `filePath`, `file`, and the `response` variable when
making these changes.


const textPayload = await response.text();
let parsed;
try {
parsed = JSON.parse(textPayload);
} catch {
parsed = { raw: textPayload };
}

if (!response.ok) {
console.error("Upload failed", {
status: response.status,
body: parsed,
});
process.exit(1);
}

console.log(JSON.stringify(parsed, null, 2));

if (debuggerBase && parsed.artifactId) {
const debuggerUrl = `${normalizeBaseUrl(debuggerBase)}/#/load?artifact=${encodeURIComponent(parsed.artifactId)}`;
console.log(`Debugger link: ${debuggerUrl}`);

if (open) {
openInBrowser(debuggerUrl);
console.log("Opened debugger in your default browser.");
}
}
}

main().catch((error) => {
console.error("Unexpected error", error);
process.exit(1);
});
51 changes: 43 additions & 8 deletions src/components/ProgramLoader/Loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,19 @@ import { getTraceSummary, parseTrace } from "@/lib/host-call-trace";

type LoaderStep = "upload" | "entrypoint";

export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) => void }) => {
interface LoaderProps {
setIsDialogOpen?: (val: boolean) => void;
initialProgram?: ProgramUploadFileOutput;
initialTrace?: string;
}

export const Loader = ({ setIsDialogOpen, initialProgram, initialTrace }: LoaderProps) => {
const dispatch = useAppDispatch();
const [programLoad, setProgramLoad] = useState<ProgramUploadFileOutput>();
const [programLoad, setProgramLoad] = useState<ProgramUploadFileOutput | undefined>(initialProgram);
const [error, setError] = useState<string>();
const [currentStep, setCurrentStep] = useState<LoaderStep>("upload");
const [traceContent, setTraceContent] = useState<string | null>(null);
const [traceContent, setTraceContent] = useState<string | null>(initialTrace ?? null);
const [traceSummary, setTraceSummary] = useState<ReturnType<typeof getTraceSummary> | null>(null);

const [currentStep, setCurrentStep] = useState<LoaderStep>("upload");
// Load saved config once on mount
const savedConfig = loadSpiConfig();

Expand All @@ -56,10 +61,24 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) =
setError("");
}, [isLoading]);

// Reset step when program changes
// Handle initial trace parsing
useEffect(() => {
setCurrentStep("upload");
}, [programLoad]);
if (initialTrace) {
try {
const parsed = parseTrace(initialTrace);
setTraceSummary(getTraceSummary(parsed));
} catch (e) {
console.error("Failed to parse initial trace:", e);
}
}
}, [initialTrace]);

// Sync initialProgram to programLoad when it changes (e.g., from URL artifact loading)
useEffect(() => {
if (initialProgram) {
setProgramLoad(initialProgram);
}
}, [initialProgram]);

// Auto-switch to RAW mode when a trace is loaded
useEffect(() => {
Expand Down Expand Up @@ -221,6 +240,22 @@ export const Loader = ({ setIsDialogOpen }: { setIsDialogOpen?: (val: boolean) =
setCurrentStep("upload");
};

// Handle initialProgram changes (from URL artifact loading)
useEffect(() => {
if (!initialProgram) return;

const isSpi = initialProgram.spiProgram !== null && initialProgram.spiProgram !== undefined;
const hasTrace = initialTrace !== undefined;

if (!isSpi || hasTrace) {
// Non-SPI or has trace: auto-load immediately
handleLoad(initialProgram);
} else {
// SPI without trace: switch to entrypoint step for user to select
setCurrentStep("entrypoint");
}
}, [initialProgram, initialTrace]); // eslint-disable-line react-hooks/exhaustive-deps

return (
<div className="flex flex-col w-full h-full bg-card pb-3 min-w-[50vw]">
<p className="sm:mb-4 bg-brand-dark dark:bg-brand/65 text-white text-xs font-light px-3 pt-3 pb-2">
Expand Down
7 changes: 7 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly VITE_ARTIFACTS_BASE_URL?: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
32 changes: 32 additions & 0 deletions src/lib/artifact-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { buildArtifactDownloadUrl } from "./artifact-url";

describe("buildArtifactDownloadUrl", () => {
it("returns absolute URLs as-is", () => {
const url = buildArtifactDownloadUrl("https://artifacts.example.com/artifacts/abc123");

expect(url).toBe("https://artifacts.example.com/artifacts/abc123");
});
Comment on lines +5 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't accept arbitrary absolute URLs from artifact query params.

Locking in absolute-URL passthrough means /load?artifact=https://... can make the browser fetch attacker-chosen origins. Please restrict this to artifact IDs, or validate absolute URLs against an explicit allowlist/schema before use. As per coding guidelines, "Validate external inputs with Zod when parsing user data".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/artifact-url.test.ts` around lines 5 - 9, The test and function
currently accept arbitrary absolute URLs via buildArtifactDownloadUrl; change
buildArtifactDownloadUrl to reject or sanitize absolute URLs from the `artifact`
query input (only accept artifact IDs or validated URLs) by validating input
with Zod or an explicit allowlist/schema, and update artifact-url.test.ts to
assert that passing an absolute URL either throws/returns an error or is
rejected, referencing the buildArtifactDownloadUrl function and the test case
name "returns absolute URLs as-is" so you can replace it with a
validation/assertion that enforces artifact-ID-only behavior or allowlisted
origins.


it("builds URL from base and artifact ID", () => {
const url = buildArtifactDownloadUrl("abc123", "https://artifacts.example.com/");

expect(url).toBe("https://artifacts.example.com/artifacts/abc123");
});

it("falls back to origin when base URL is missing", () => {
const url = buildArtifactDownloadUrl("abc123", undefined, "http://localhost:5173");

expect(url).toBe("http://localhost:5173/artifacts/abc123");
});

it("throws for empty artifact reference", () => {
expect(() => buildArtifactDownloadUrl(" ", "https://artifacts.example.com")).toThrow(
"Artifact reference is empty",
);
});

it("throws when it cannot resolve base URL", () => {
expect(() => buildArtifactDownloadUrl("abc123")).toThrow("Missing artifacts base URL");
});
});
19 changes: 19 additions & 0 deletions src/lib/artifact-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const HTTP_URL_PATTERN = /^https?:\/\//i;

export function buildArtifactDownloadUrl(artifactRef: string, baseUrl?: string, fallbackOrigin?: string): string {
const normalizedArtifactRef = artifactRef.trim();
if (!normalizedArtifactRef) {
throw new Error("Artifact reference is empty");
}

if (HTTP_URL_PATTERN.test(normalizedArtifactRef)) {
return normalizedArtifactRef;
}

const normalizedBaseUrl = (baseUrl?.trim() || fallbackOrigin?.trim() || "").replace(/\/+$/, "");
if (!normalizedBaseUrl) {
throw new Error("Missing artifacts base URL");
}

return `${normalizedBaseUrl}/artifacts/${encodeURIComponent(normalizedArtifactRef)}`;
}
Loading
Loading