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
- Regenerate the key via the Railway connection's SSH key regeneration flow (generateRailwaySshKey) and re-upload the public half to Railway
- Confirm the stored secret starts with '-----BEGIN OPENSSH PRIVATE KEY-----' and ends with the matching footer
- 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
- 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
- Always generate keys via generateRailwaySshKey (ed25519, OpenSSH format) instead of importing legacy keys
- Verify the secret at railway.ssh_private_key was not overwritten with the public key or a placeholder
- Preserve the full PEM including BEGIN/END header lines through any copy/paste or migration
- After upgrading Paperclip, regenerate keys created by older versions emitting legacy PEM
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
- railway_ssh_host_key_invalid
- railway_ssh_setup_required
- railway_ssh_unavailable
- railway_target_mismatch
- ACPX profile requires exact model ; received
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)