From b7e6c3a5f63f96a0c7ab49dbc583c627f524d6b1 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:12:42 +0000 Subject: [PATCH 1/3] fix(security): use temp file for GitHub token to avoid process listing exposure Fixes #3300 Agent: security-auditor Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/package.json | 2 +- packages/cli/src/shared/agent-setup.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 06dca7d7c..038481a24 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.9", + "version": "1.0.10", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 9412dd0cd..198aa2108 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -267,8 +267,11 @@ export async function offerGithubAuth(runner: CloudRunner, explicitlyRequested?: let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash"; if (githubToken) { - const tokenB64 = Buffer.from(githubToken).toString("base64"); - ghCmd = `export GITHUB_TOKEN=$(printf '%s' ${shellQuote(tokenB64)} | base64 -d) && ${ghCmd}`; + const tmpFile = join(getTmpDir(), `spawn_gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`); + writeFileSync(tmpFile, githubToken, { + mode: 0o600, + }); + ghCmd = `export GITHUB_TOKEN=$(cat ${shellQuote(tmpFile)}) && rm -f ${shellQuote(tmpFile)} && ${ghCmd}`; } logStep("Installing and authenticating GitHub CLI on the remote server..."); From 76811d7fe130240c3fa36433453adb89483237ba Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:57:33 +0000 Subject: [PATCH 2/3] fix(security): pass GitHub token via heredoc instead of local temp file The previous fix wrote the token to a temp file on the LOCAL host, but the command string was executed on the REMOTE server via runner.runServer(), so `cat` would fail with 'No such file or directory'. Switch to a heredoc which is parsed by the remote shell and never appears in /proc/*/cmdline. Agent: pr-maintainer Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/shared/agent-setup.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 198aa2108..d8b6983de 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -267,11 +267,11 @@ export async function offerGithubAuth(runner: CloudRunner, explicitlyRequested?: let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash"; if (githubToken) { - const tmpFile = join(getTmpDir(), `spawn_gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`); - writeFileSync(tmpFile, githubToken, { - mode: 0o600, - }); - ghCmd = `export GITHUB_TOKEN=$(cat ${shellQuote(tmpFile)}) && rm -f ${shellQuote(tmpFile)} && ${ghCmd}`; + // Pass token via heredoc to avoid exposing it in process listings (`ps auxe`). + // The heredoc is read by the shell's parser, not passed as a command argument, + // so it never appears in /proc/*/cmdline. A local temp file won't work here + // because the command executes on the REMOTE server via runner.runServer(). + ghCmd = `export GITHUB_TOKEN=$(cat <<'SPAWN_TOKEN_EOF'\n${githubToken}\nSPAWN_TOKEN_EOF\n) && ${ghCmd}`; } logStep("Installing and authenticating GitHub CLI on the remote server..."); From 8465ad4e780fd801d52708c82579ad40b41ce231 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:44:39 +0000 Subject: [PATCH 3/3] fix(security): upload token to remote via SCP instead of heredoc The previous heredoc approach (`cat <<'EOF'`) doesn't work because all cloud runners wrap commands in `bash -c ${shellQuote(cmd)}`, and heredocs are not valid inside single-quoted bash -c strings. Use runner.uploadFile() (SCP) to place the token on the remote server as a temp file (mode 0600), then cat+rm it in the remote command. This is the same proven pattern used by uploadConfigFile(). The local temp file is always cleaned up after upload, and the remote temp file is cleaned up both on success (inline rm) and on failure (best-effort rm). Agent: security-auditor Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/shared/agent-setup.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index d8b6983de..a0c8cf0b0 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -266,17 +266,33 @@ export async function offerGithubAuth(runner: CloudRunner, explicitlyRequested?: } let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash"; + // Upload the token to a remote temp file so it never appears in `ps auxe` + // process listings. We use runner.uploadFile() (SCP) — the same proven + // pattern as uploadConfigFile(). A heredoc won't work here because all + // cloud runners wrap commands in `bash -c ${shellQuote(cmd)}`, and + // heredocs are not valid inside single-quoted `bash -c '...'` strings. + let remoteTokenPath = ""; if (githubToken) { - // Pass token via heredoc to avoid exposing it in process listings (`ps auxe`). - // The heredoc is read by the shell's parser, not passed as a command argument, - // so it never appears in /proc/*/cmdline. A local temp file won't work here - // because the command executes on the REMOTE server via runner.runServer(). - ghCmd = `export GITHUB_TOKEN=$(cat <<'SPAWN_TOKEN_EOF'\n${githubToken}\nSPAWN_TOKEN_EOF\n) && ${ghCmd}`; + const localTmpFile = join(getTmpDir(), `spawn_gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`); + remoteTokenPath = `/tmp/spawn_gh_token_${Date.now()}`; + writeFileSync(localTmpFile, githubToken, { + mode: 0o600, + }); + const uploadResult = await asyncTryCatch(() => runner.uploadFile(localTmpFile, remoteTokenPath)); + tryCatchIf(isOperationalError, () => unlinkSync(localTmpFile)); + if (!uploadResult.ok) { + throw uploadResult.error; + } + ghCmd = `export GITHUB_TOKEN=$(cat ${shellQuote(remoteTokenPath)}) && rm -f ${shellQuote(remoteTokenPath)} && ${ghCmd}`; } logStep("Installing and authenticating GitHub CLI on the remote server..."); const ghSetup = await asyncTryCatchIf(isOperationalError, () => runner.runServer(ghCmd)); if (!ghSetup.ok) { + // Best-effort cleanup of remote token file if the command failed before rm ran + if (remoteTokenPath) { + await asyncTryCatchIf(isOperationalError, () => runner.runServer(`rm -f ${shellQuote(remoteTokenPath)}`)); + } logWarn("GitHub CLI setup failed (non-fatal, continuing)"); }