{"record":{"id":"0e647bc4e0dfe05f","repo":"can1357/oh-my-pi","slug":"ssh-empty-port-in-url-href-use-ssh-hos","errorCode":null,"errorMessage":"ssh://: empty port in \"${url.href}\"; use ssh://host:<1-65535>/<path> or drop the colon","messagePattern":"ssh://: empty port in \"(.+?)\"; use ssh://host:<1-65535>/<path> or drop the colon","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/internal-urls/ssh-protocol.ts","lineNumber":195,"sourceCode":"\t\tthrow new Error(\"ssh://: port 0 is not a valid SSH port; use ssh://host:<1-65535>/<path> or omit the port\");\n\t}\n\t// An empty port (`ssh://prod:/path`, `ssh://user@host:/path`, including\n\t// percent-encoded authority parts) parses cleanly with `url.port === \"\"`, so it\n\t// slips past the malformed-authority guard and would be read as \"no port\" —\n\t// silently using the default/configured target. `url.rawHost` is the decoded\n\t// authority and uniquely retains the trailing `:`; comparing it to the decoded\n\t// host (+ user) catches the empty port, while a percent-encoded alias like\n\t// `prod%3A` (whose decoded host already ends in `:`) reconstructs to `prod::`\n\t// and is left alone.\n\tconst decodeOr = (s: string): string => {\n\t\ttry {\n\t\t\treturn decodeURIComponent(s);\n\t\t} catch {\n\t\t\treturn s;\n\t\t}\n\t};\n\tif (port === undefined && url.rawHost === `${username ? `${decodeOr(username)}@` : \"\"}${decodeOr(bareHost)}:`) {\n\t\tthrow new Error(`ssh://: empty port in \"${url.href}\"; use ssh://host:<1-65535>/<path> or drop the colon`);\n\t}\n\t// A literal but empty userinfo (`ssh://@host`) sets username to \"\" — WHATWG drops\n\t// the `@` from hostname, but rawHost keeps the leading `@`. A percent-encoded\n\t// alias like `%40prod` decodes to `@prod` in rawHost too, but its hostname keeps\n\t// `%40`, so the reconstruction is `@@prod` and is left alone.\n\tif (username === undefined && url.rawHost === `@${decodeOr(bareHost)}${port !== undefined ? `:${port}` : \"\"}`) {\n\t\tthrow new Error(`ssh://: empty username in \"${url.href}\"; drop the leading '@' or provide a username before it`);\n\t}\n\t// Backstop for any remaining stray/empty authority marker the explicit checks\n\t// above do not name — notably an empty password (`ssh://user:@host`, `ssh://:@host`,\n\t// where `url.password === \"\"`). `rawHost` keeps the literal marker, so it differs\n\t// from the canonical decoded `[user@]host[:port]` WHATWG actually parsed. Every\n\t// valid authority — including percent-encoded reserved-char aliases — reconstructs\n\t// to exactly `rawHost`, so only malformed userinfo trips this.\n\tconst canonicalAuthority = `${url.username ? `${decodeOr(url.username)}@` : \"\"}${decodeOr(bareHost)}${port !== undefined ? `:${port}` : \"\"}`;\n\tif (url.rawHost !== canonicalAuthority) {\n\t\tthrow new Error(\n\t\t\t`ssh://: unsupported or malformed authority in \"${url.href}\"; use ssh://[user@]host[:1-65535]/<absolute-path>`,","sourceCodeStart":177,"sourceCodeEnd":213,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/internal-urls/ssh-protocol.ts#L177-L213","documentation":"WHATWG URL parsing accepts `ssh://prod:/path` with `url.port === \"\"` — an empty port after a trailing colon. That would silently be treated as 'no port' and connect to the default/configured target, so the handler detects the dangling colon via `rawHost` and rejects it.","triggerScenarios":"Resolving `ssh://prod:/path` or `ssh://user@host:/path`, including with percent-encoded authority parts, where `port === undefined` but `rawHost` still ends with a literal `:`.","commonSituations":"scp-style strings (`scp file prod:/tmp`) copy-pasted into an ssh:// URL; a template producing `ssh://${host}:${port}/` with an empty port variable; hand-edited URLs leaving a stray colon.","solutions":["Drop the trailing colon: `ssh://prod/path`","Supply an actual port: `ssh://prod:2222/path`","Fix the template/config that left the port variable empty"],"exampleFix":"// before\nresolve('ssh://prod:/etc/hosts')\n// after\nresolve('ssh://prod/etc/hosts')","handlingStrategy":"validation","validationCode":"const u = new URL(candidate);\nif (u.protocol === 'ssh:' && u.port === '' && /:$/.test(u.rawHost ?? '')) throw new Error(`drop the trailing colon: ${candidate}`);","typeGuard":"function hasNoEmptySshPort(u: URL): boolean { return !(u.port === '' && (u.rawHost ?? '').endsWith(':')); }","tryCatchPattern":"try {\n  const res = await handler.resolve(url, ctx);\n} catch (e) {\n  if (e instanceof Error && e.message.includes('empty port in')) {\n    // remove the stray ':' or supply a port, then retry\n  } else throw e;\n}","preventionTips":["Don't convert scp-style `host:/path` strings verbatim to ssh:// URLs — drop the colon","Guard template interpolation: emit `:${port}` only when port is non-empty","Trim stray separators when hand-editing URLs"],"tags":["ssh","url-parsing","malformed-url"],"backgroundTag":"malformed-url-authority","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}