paperclipai/paperclip · error · RailwayError

railway_ssh_key_invalid

railway_ssh_key_invalid

Error message

Regenerate the Railway connection's SSH key.

What it means

RailwayError (code railway_ssh_key_invalid, HTTP 422) thrown by runRailwaySshCommand when the stored private key does not begin with the OpenSSH PEM header '-----BEGIN OPENSSH PRIVATE KEY-----'. The connection's key material is in a legacy or corrupted format that ssh with StrictHostKeyChecking and BatchMode cannot use, so the library fails fast and asks the user to regenerate the key.

Solutions

  1. Regenerate the key via the Railway connection's SSH key regeneration flow (generateRailwaySshKey) and re-upload the public half to Railway
  2. Confirm the stored secret starts with '-----BEGIN OPENSSH PRIVATE KEY-----' and ends with the matching footer
  3. If the key is legacy PEM, convert it: ssh-keygen -p -m RFC4716? no — use `ssh-keygen -p -f key` to rewrite in OpenSSH format
  4. Verify the secret path (railway.ssh_private_key) was not overwritten by a public key or placeholder during config

Example fix

// before
privateKey: fs.readFileSync("id_rsa_old", "utf8") // BEGIN RSA PRIVATE KEY
// after
execSync("ssh-keygen -p -f id_rsa_old -N ''"); // rewrite as OPENSSH format
privateKey: fs.readFileSync("id_rsa_old", "utf8") // BEGIN OPENSSH PRIVATE KEY
Defensive patterns

Strategy: validation

Validate before calling

function isOpenSshPrivateKey(key) {
  return typeof key === "string" && key.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----");
}
if (!isOpenSshPrivateKey(storedKey)) {
  // prompt regeneration before attempting any SSH command
}

Type guard

function hasValidRailwayPrivateKey(input) {
  return typeof input.privateKey === "string" &&
    input.privateKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----");
}

Try / catch

try {
  await runRailwaySshCommand({ ...input, privateKey, knownHosts });
} catch (e) {
  if (e.code === "railway_ssh_key_invalid") {
    await regenerateAndStoreRailwaySshKey(connectionId); // then retry once
    return runRailwaySshCommand({ ...input, privateKey: freshKey, knownHosts });
  }
  throw e;
}

Prevention

When it happens

Trigger: runRailwaySshCommand receives input.privateKey that is a PEM RSA key ('-----BEGIN RSA PRIVATE KEY-----'), a PuTTY .ppk, a public key pasted by mistake, a redacted/placeholder value, or an empty string.

Common situations: Older connections created before the switch to ed25519 OpenSSH format; user pasted the .pub file contents; secrets migration truncated or redacted the key; key generated by a tool emitting legacy PEM; copy/paste lost the header line.

Understand the failure class

Background: "Invalid value" and "allowed values are" config errors: what your library rejected and how to fix it — this error's family across 41 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/9b75d46b9e3effc0. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/railway-ssh.ts:48

}

export function railwaySshArguments(directory: string, instanceId: string): string[] {
  if (!/^[a-f0-9-]{36}$/i.test(instanceId)) throw new RailwayError("railway_target_mismatch", "Invalid Railway container instance.", 400);
  return [
    "-F", "/dev/null", "-T", "-i", path.join(directory, "identity"),
    "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none",
    "-o", "ForwardAgent=no", "-o", "ClearAllForwardings=yes", "-o", "ControlMaster=no",
    "-o", "ControlPath=none", "-o", "PermitLocalCommand=no", "-o", "StrictHostKeyChecking=yes",
    "-o", `UserKnownHostsFile=${path.join(directory, "known_hosts")}`, "-o", "GlobalKnownHostsFile=/dev/null",
    "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2",
    "--", `${instanceId}@ssh.railway.com`, "sh -s",
  ];
}

export async function runRailwaySshCommand(input: RailwaySshInput & { privateKey: string; knownHosts: string }) {
  input.signal.throwIfAborted();
  const knownHosts = validateRailwayKnownHosts(input.knownHosts);
  if (!input.privateKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) throw new RailwayError("railway_ssh_key_invalid", "Regenerate the Railway connection's SSH key.", 422);
  const directory = await mkdtemp(path.join(tmpdir(), "paperclip-railway-command-"));
  try {
    await writeFile(path.join(directory, "identity"), input.privateKey, { mode: 0o600 });
    await writeFile(path.join(directory, "known_hosts"), knownHosts, { mode: 0o600 });
    input.signal.throwIfAborted();
    return await new Promise<{ exitCode: number | null; stdout: string; stderr: string; truncated: boolean; timedOut: boolean }>((resolve, reject) => {
      // No developer SSH config/agent, CLI login, provider token or ambient env.
      const child = spawn("/usr/bin/ssh", railwaySshArguments(directory, input.deploymentInstanceId), { env: { PATH: "/usr/bin:/bin", LANG: "C.UTF-8" }, stdio: ["pipe", "pipe", "pipe"] });
      let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false, deliveryFailed = false;
      const marker = `paperclip_railway_completed_${randomBytes(16).toString("hex")}`;
      const stop = () => { child.kill("SIGKILL"); };
      const receive = (chunk: Buffer, stream: "out" | "err") => {
        const remaining = Math.max(0, 64 * 1024 - bytes);
        bytes += chunk.length;
        const text = chunk.subarray(0, remaining).toString("utf8");
        if (stream === "out") stdout += text; else stderr += text;
        if (bytes > 64 * 1024) { truncated = true; stop(); }
      };

View on GitHub (pinned to 3f1d897a7c)