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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.20.4",
"version": "0.20.5",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/aws/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,7 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
...SSH_BASE_OPTS,
...keyOpts,
localPath,
`${SSH_USER}@${_state.instanceIp}:${remotePath}`,
`${SSH_USER}@${_state.instanceIp}:${normalizedRemote}`,
],
{
stdio: [
Expand Down Expand Up @@ -1176,7 +1176,7 @@ export async function downloadFile(remotePath: string, localPath: string): Promi
throw new Error(`Invalid remote path: ${remotePath}`);
}
const keyOpts = getSshKeyOpts(await ensureSshKeys());
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
const proc = Bun.spawn(
[
"scp",
Expand Down
19 changes: 11 additions & 8 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";

import { mkdirSync, readFileSync } from "node:fs";
import { normalize } from "node:path";
import * as p from "@clack/prompts";
import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
Expand Down Expand Up @@ -1307,10 +1308,11 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string):

export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise<void> {
const serverIp = ip || _state.serverIp;
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
Expand All @@ -1323,7 +1325,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str
...SSH_BASE_OPTS,
...keyOpts,
localPath,
`root@${serverIp}:${remotePath}`,
`root@${serverIp}:${normalizedRemote}`,
],
{
stdio: [
Expand All @@ -1346,17 +1348,18 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str

export async function downloadFile(remotePath: string, localPath: string, ip?: string): Promise<void> {
const serverIp = ip || _state.serverIp;
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
}

const keyOpts = getSshKeyOpts(await ensureSshKeys());
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");

const proc = Bun.spawn(
[
Expand Down
20 changes: 11 additions & 9 deletions packages/cli/src/gcp/gcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { join, normalize } from "node:path";
import { isString, toObjectArray } from "@openrouter/spawn-shared";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
Expand Down Expand Up @@ -997,17 +997,18 @@ export async function uploadFile(localPath: string, remotePath: string): Promise
logError(`Invalid local path: ${localPath}`);
throw new Error("Invalid local path");
}
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
}
const username = resolveUsername();
// Expand $HOME on remote side
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
const keyOpts = getSshKeyOpts(await ensureSshKeys());

const proc = Bun.spawn(
Expand Down Expand Up @@ -1043,16 +1044,17 @@ export async function downloadFile(remotePath: string, localPath: string): Promi
logError(`Invalid local path: ${localPath}`);
throw new Error("Invalid local path");
}
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
}
const username = resolveUsername();
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");
const keyOpts = getSshKeyOpts(await ensureSshKeys());

const proc = Bun.spawn(
Expand Down
19 changes: 11 additions & 8 deletions packages/cli/src/hetzner/hetzner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CloudInstance, VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";

import { mkdirSync, readFileSync } from "node:fs";
import { normalize } from "node:path";
import { getErrorMessage, isNumber, isString, toObjectArray, toRecord } from "@openrouter/spawn-shared";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
Expand Down Expand Up @@ -615,10 +616,11 @@ export async function runServer(cmd: string, timeoutSecs?: number, ip?: string):

export async function uploadFile(localPath: string, remotePath: string, ip?: string): Promise<void> {
const serverIp = ip || _state.serverIp;
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
Expand All @@ -632,7 +634,7 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str
...SSH_BASE_OPTS,
...keyOpts,
localPath,
`root@${serverIp}:${remotePath}`,
`root@${serverIp}:${normalizedRemote}`,
],
{
stdio: [
Expand All @@ -655,17 +657,18 @@ export async function uploadFile(localPath: string, remotePath: string, ip?: str

export async function downloadFile(remotePath: string, localPath: string, ip?: string): Promise<void> {
const serverIp = ip || _state.serverIp;
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
}

const keyOpts = getSshKeyOpts(await ensureSshKeys());
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");

const proc = Bun.spawn(
[
Expand Down
16 changes: 9 additions & 7 deletions packages/cli/src/shared/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { AgentConfig } from "./agents";
import type { Result } from "./ui";

import { unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { join, normalize } from "node:path";
import { getErrorMessage } from "@openrouter/spawn-shared";
import { getTmpDir } from "./paths";
import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js";
Expand Down Expand Up @@ -64,24 +64,26 @@ async function installAgent(
* Allows shell variable references ($HOME, ${HOME}) but rejects anything
* that could break out of double-quoted shell interpolation.
*/
function validateRemotePath(remotePath: string): void {
function validateRemotePath(remotePath: string): string {
// Allow alphanumerics, forward slashes, dots, underscores, tildes, hyphens,
// and shell variable syntax ($, {, }). Reject everything else — especially
// backticks, semicolons, pipes, quotes, newlines, and null bytes.
if (!/^[\w/.~${}:-]+$/.test(remotePath)) {
const normalizedRemote = normalize(remotePath);
if (!/^[\w/.~${}:-]+$/.test(normalizedRemote)) {
throw new Error(`uploadConfigFile: remotePath contains unsafe characters: ${remotePath}`);
}
// Block path traversal
if (remotePath.includes("..")) {
// Block path traversal (normalize resolves . segments first)
if (normalizedRemote.includes("..")) {
throw new Error(`uploadConfigFile: remotePath must not contain "..": ${remotePath}`);
}
return normalizedRemote;
}

/**
* Upload a config file to the remote machine via a temp file and mv.
*/
async function uploadConfigFile(runner: CloudRunner, content: string, remotePath: string): Promise<void> {
validateRemotePath(remotePath);
const safePath = validateRemotePath(remotePath);

const tmpFile = join(getTmpDir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`);
writeFileSync(tmpFile, content, {
Expand All @@ -97,7 +99,7 @@ async function uploadConfigFile(runner: CloudRunner, content: string, remotePath
const tempRemote = `/tmp/spawn_config_${Date.now()}`;
await runner.uploadFile(tmpFile, tempRemote);
await runner.runServer(
`mkdir -p $(dirname "${remotePath}") && chmod 600 ${shellQuote(tempRemote)} && mv ${shellQuote(tempRemote)} "${remotePath}"`,
`mkdir -p $(dirname "${safePath}") && chmod 600 ${shellQuote(tempRemote)} && mv ${shellQuote(tempRemote)} "${safePath}"`,
);
})(),
),
Expand Down
22 changes: 12 additions & 10 deletions packages/cli/src/sprite/sprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { VMConnection } from "../history.js";

import { existsSync } from "node:fs";
import { join } from "node:path";
import { join, normalize } from "node:path";
import { getErrorMessage } from "@openrouter/spawn-shared";
import { getUserHome } from "../shared/paths";
import { asyncTryCatch } from "../shared/result.js";
Expand Down Expand Up @@ -506,10 +506,11 @@ async function runSpriteSilent(cmd: string): Promise<void> {
* The -file flag format is "localpath:remotepath".
*/
export async function uploadFileSprite(localPath: string, remotePath: string): Promise<void> {
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
Expand All @@ -518,7 +519,7 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P
const spriteCmd = getSpriteCmd()!;
// Generate a random temp path on remote to prevent symlink attacks
const tempRandom = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
const basename = remotePath.split("/").pop() || "file";
const basename = normalizedRemote.split("/").pop() || "file";
const tempRemote = `/tmp/sprite_upload_${basename}_${tempRandom}`;

await spriteRetry("sprite upload", async () => {
Expand All @@ -534,7 +535,7 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P
"--",
"bash",
"-c",
`mkdir -p $(dirname '${remotePath}') && mv '${tempRemote}' '${remotePath}'`,
`mkdir -p $(dirname '${normalizedRemote}') && mv '${tempRemote}' '${normalizedRemote}'`,
],
{
stdio: [
Expand All @@ -555,17 +556,18 @@ export async function uploadFileSprite(localPath: string, remotePath: string): P

/** Download a file from the remote sprite by catting it to stdout. */
export async function downloadFileSprite(remotePath: string, localPath: string): Promise<void> {
const normalizedRemote = normalize(remotePath);
if (
!/^[a-zA-Z0-9/_.~$-]+$/.test(remotePath) ||
remotePath.includes("..") ||
remotePath.split("/").some((s) => s.startsWith("-"))
!/^[a-zA-Z0-9/_.~$-]+$/.test(normalizedRemote) ||
normalizedRemote.includes("..") ||
normalizedRemote.split("/").some((s) => s.startsWith("-"))
) {
logError(`Invalid remote path: ${remotePath}`);
throw new Error("Invalid remote path");
}

const spriteCmd = getSpriteCmd()!;
const expandedPath = remotePath.replace(/^\$HOME/, "~");
const expandedPath = normalizedRemote.replace(/^\$HOME/, "~");

await spriteRetry("sprite download", async () => {
const proc = Bun.spawn(
Expand Down
Loading