Skip to content

feat(cli): add image commands for cloud storage management#728

Merged
ddupont808 merged 4 commits intomainfrom
feat/cli-images-command
Jan 14, 2026
Merged

feat(cli): add image commands for cloud storage management#728
ddupont808 merged 4 commits intomainfrom
feat/cli-images-command

Conversation

@r33drichards
Copy link
Copy Markdown
Collaborator

@r33drichards r33drichards commented Jan 9, 2026

Summary

  • Adds cua image command group to manage VM images in cloud storage
  • Implements multipart upload for large image files (up to several GB)
  • Includes checksum verification for downloads
  • Integrates with the golden image cloud storage API from fix(workflows): prevent ghost runs and handle existing tags #758
  • Auto-detects local cua-bench images by name

Commands Added

Command Description
cua image list List all images in your workspace
cua image upload <name> Upload a VM image (auto-detects from cua-bench storage)
cua image download <name> Download a VM image with checksum verification
cua image delete <name> --force Delete an image version

Usage Examples

# List images
cua image list

# Upload a local cua-bench image by name
cua image upload my-windows-image --tag v1.0

# Upload with explicit file path
cua image upload my-windows-image --file ./custom/path.qcow2 --tag v1.0

# Download an image
cua image download my-windows-image --tag v1.0 --output ./downloaded.qcow2

# Delete an image version
cua image delete my-windows-image --tag v1.0 --force

Integration with cua-bench

The upload command automatically looks for images in the cua-bench local storage:

~/.local/share/cua-bench/images/<name>/data.img

This enables a seamless workflow:

# 1. Create image locally with cua-bench
cb image create windows-qemu --name my-osworld-base

# 2. Upload to cloud storage
cua image upload my-osworld-base --tag v1.0

# 3. Use in cloud sandboxes (future: cua sandbox create --image my-osworld-base:v1.0)

Test plan

  • Run cua image --help to verify command structure
  • Test cua image list with valid credentials
  • Test multipart upload with a test file
  • Verify checksum validation on download
  • Test error handling for invalid credentials

🤖 Generated with Claude Code

Adds `cua image` command group to manage VM images in cloud storage:
- `cua image list` - List all images in workspace
- `cua image upload <file>` - Upload VM image with multipart upload
- `cua image download <name>` - Download VM image with checksum verification
- `cua image delete <name>` - Delete image version

Integrates with the golden image cloud storage API from trycua/cloud#758.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings January 9, 2026 21:32
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jan 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
docs Ready Ready Preview, Comment Jan 14, 2026 6:49pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new cua image command group to manage VM images in cloud storage, enabling users to upload, download, list, and delete VM images with features like multipart upload for large files and checksum verification.

Key changes:

  • Implements multipart upload functionality for handling large image files (up to several GB)
  • Adds checksum verification (SHA256) for both uploads and downloads
  • Provides four commands: list, upload, download, and delete with appropriate options

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
libs/typescript/cua-cli/src/commands/image.ts New file implementing all image management commands with multipart upload, checksum verification, and error handling
libs/typescript/cua-cli/src/cli.ts Integrates image commands into the CLI and updates help text to include image command documentation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +39 to +41
// Default part size: 100MB
const DEFAULT_PART_SIZE = 100 * 1024 * 1024;

Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

The DEFAULT_PART_SIZE constant is defined but never used in the code. The actual part size is determined by the server in the UploadSession response. Consider removing this unused constant or documenting its purpose if it's meant for reference.

Suggested change
// Default part size: 100MB
const DEFAULT_PART_SIZE = 100 * 1024 * 1024;

Copilot uses AI. Check for mistakes.
Comment on lines +227 to +228
// Get ETag from response
const etag = uploadRes.headers.get('ETag') || '';
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

If the ETag header is missing from the upload response, an empty string is used, which could cause the multipart upload completion to fail. Consider adding validation to ensure the ETag is present, and abort the upload if it's missing.

Suggested change
// Get ETag from response
const etag = uploadRes.headers.get('ETag') || '';
// Get ETag from response and validate it
const etag = uploadRes.headers.get('ETag');
if (!etag) {
console.error(`\nMissing ETag header for part ${partNum}`);
// Abort upload
await http(`/v1/images/${encodeURIComponent(name)}/upload/${session.upload_id}`, {
token,
method: 'DELETE',
});
process.exit(1);
}

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +186
const fileBuffer = await file.arrayBuffer();

for (let partNum = 1; partNum <= session.total_parts; partNum++) {
const start = (partNum - 1) * session.part_size;
const end = Math.min(start + session.part_size, sizeBytes);
const partData = fileBuffer.slice(start, end);
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

Loading the entire file into memory with await file.arrayBuffer() is inefficient and could cause memory issues for large files (several GB as mentioned in the PR description). Consider using Bun's streaming API to read the file in chunks instead. You can use file.slice(start, end) for each part without loading the entire file into memory first.

Suggested change
const fileBuffer = await file.arrayBuffer();
for (let partNum = 1; partNum <= session.total_parts; partNum++) {
const start = (partNum - 1) * session.part_size;
const end = Math.min(start + session.part_size, sizeBytes);
const partData = fileBuffer.slice(start, end);
for (let partNum = 1; partNum <= session.total_parts; partNum++) {
const start = (partNum - 1) * session.part_size;
const end = Math.min(start + session.part_size, sizeBytes);
const partData = file.slice(start, end);

Copilot uses AI. Check for mistakes.
// Helper to calculate SHA256 hash of a file
async function calculateFileHash(filePath: string): Promise<string> {
const file = Bun.file(filePath);
const buffer = await file.arrayBuffer();
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

Loading the entire file into memory to calculate the hash is inefficient for large files. Consider using a streaming approach where you read the file in chunks and update the hash incrementally. This would prevent memory issues with multi-GB files.

Copilot uses AI. Check for mistakes.
Comment on lines +296 to +297
const buffer = await downloadRes.arrayBuffer();
await Bun.write(outputPath, buffer);
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

Loading the entire download into memory with await downloadRes.arrayBuffer() is inefficient for large files. Consider streaming the response directly to disk using Bun's streaming capabilities or writing in chunks to avoid memory issues with multi-GB files.

Suggested change
const buffer = await downloadRes.arrayBuffer();
await Bun.write(outputPath, buffer);
await Bun.write(outputPath, downloadRes);

Copilot uses AI. Check for mistakes.
Comment on lines +303 to +307
if (downloadedChecksum !== urlData.checksum_sha256) {
console.error('Checksum mismatch! Download may be corrupted.');
console.error(`Expected: ${urlData.checksum_sha256}`);
console.error(`Got: ${downloadedChecksum}`);
process.exit(1);
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.

When checksum verification fails after downloading a file, the corrupted file is left on disk and the process exits without cleanup.

View Details
📝 Patch Details
diff --git a/libs/typescript/cua-cli/src/commands/image.ts b/libs/typescript/cua-cli/src/commands/image.ts
index b746d973..91b1d2d8 100644
--- a/libs/typescript/cua-cli/src/commands/image.ts
+++ b/libs/typescript/cua-cli/src/commands/image.ts
@@ -304,6 +304,7 @@ const downloadHandler = async (argv: Record<string, unknown>) => {
     console.error('Checksum mismatch! Download may be corrupted.');
     console.error(`Expected: ${urlData.checksum_sha256}`);
     console.error(`Got: ${downloadedChecksum}`);
+    await Bun.file(outputPath).delete();
     process.exit(1);
   }
 

Analysis

Corrupted file not cleaned up when checksum verification fails in downloadHandler

What fails: In downloadHandler() at libs/typescript/cua-cli/src/commands/image.ts, when checksum verification fails after downloading a file, the corrupted file is left on disk and the process exits without cleanup.

How to reproduce:

  1. Call the download command with an image that will have a corrupted checksum
  2. The file is written to disk via await Bun.write(outputPath, buffer) at line 297
  3. Checksum verification happens at line 301
  4. If the checksum doesn't match (line 303-307), the process exits via process.exit(1) without deleting the corrupted file

What happens: The corrupted file remains on disk after the download fails due to checksum mismatch, potentially confusing users who may later attempt to use the corrupted file without realizing it failed verification.

Expected: When checksum verification fails, the corrupted file should be deleted before the process exits, per Bun file I/O documentation.

Fix: Added await Bun.file(outputPath).delete(); before process.exit(1) in the checksum verification failure block.

const token = await ensureApiKeyInteractive();
const name = String(argv.name);
const tag = String(argv.tag || 'latest');
const outputPath = String(argv.output || `${name}-${tag}.qcow2`);
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.

The download command's --output parameter accepts arbitrary file paths without validation, allowing path traversal attacks (e.g., ../../sensitive/file).

View Details
📝 Patch Details
diff --git a/libs/typescript/cua-cli/src/commands/image.ts b/libs/typescript/cua-cli/src/commands/image.ts
index b746d973..8be1c368 100644
--- a/libs/typescript/cua-cli/src/commands/image.ts
+++ b/libs/typescript/cua-cli/src/commands/image.ts
@@ -1,5 +1,6 @@
 import type { Argv } from 'yargs';
 import { createHash } from 'crypto';
+import { resolve, relative } from 'path';
 import { ensureApiKeyInteractive } from '../auth';
 import { http } from '../http';
 import { clearApiKey } from '../storage';
@@ -70,6 +71,24 @@ function printTable(headers: string[], rows: string[][]) {
     console.log(r.map((c, i) => (c ?? '').padEnd(widths[i] ?? 0)).join('  '));
 }
 
+// Helper to validate output path against directory traversal attacks
+function validateOutputPath(outputPath: string): void {
+  const cwd = process.cwd();
+  const resolvedPath = resolve(outputPath);
+  const relativePath = relative(cwd, resolvedPath);
+
+  // Check if the resolved path escapes the current working directory
+  if (relativePath.startsWith('..')) {
+    console.error(`Error: Output path "${outputPath}" escapes the current working directory`);
+    process.exit(1);
+  }
+
+  // Check if it's an absolute path
+  if (resolve(outputPath) !== resolvedPath.replace(cwd, cwd)) {
+    // This is already checked above, but kept for clarity
+  }
+}
+
 // Command handlers
 const listHandler = async (argv: Record<string, unknown>) => {
   const token = await ensureApiKeyInteractive();
@@ -258,6 +277,7 @@ const downloadHandler = async (argv: Record<string, unknown>) => {
   const name = String(argv.name);
   const tag = String(argv.tag || 'latest');
   const outputPath = String(argv.output || `${name}-${tag}.qcow2`);
+  validateOutputPath(outputPath);
 
   console.log(`Downloading ${name}:${tag}...`);
 

Analysis

Path Traversal Vulnerability in image download command allows arbitrary file writes

What fails: The cua image download command accepts user-provided output paths without validation, allowing attackers to write files outside the current working directory using path traversal sequences like ../../.

How to reproduce:

# In a safe directory, run:
cua image download myimage --output "../../etc/malicious.txt"

The file would be written to /etc/malicious.txt instead of the intended location in the current working directory.

What happens: Without validation, Bun.write(outputPath, buffer) allows paths containing ../ sequences to traverse directories and write to arbitrary locations within filesystem permissions.

Expected behavior: The --output path should be validated to ensure it resolves within the current working directory, preventing directory traversal attacks. This follows OWASP guidance on path traversal prevention and matches fixes in similar tools like curl (CVE-2016-0754).

References:

- Change `cua image upload <file> --name` to `cua image upload <name>`
- Auto-detect image path from cua-bench local storage
- Add optional --file flag to specify custom path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@r33drichards
Copy link
Copy Markdown
Collaborator Author

@copilot pull and push instead of upload and download

Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 9, 2026

@r33drichards I've opened a new pull request, #731, to work on those changes. Once the pull request is ready, I'll request review from you.

* Initial plan

* refactor: rename upload/download to push/pull for image commands

Co-authored-by: r33drichards <57335981+r33drichards@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: r33drichards <57335981+r33drichards@users.noreply.github.com>
@ddupont808 ddupont808 merged commit f47690b into main Jan 14, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants