UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

148 lines (137 loc) • 7.06 kB
// Repo identity — the portable key that lets a relay open in the right repo on // the recipient's machine. // // WHAT THIS IS NOT (ANY MORE): the sender's working directory. // // Until 0.1.142 the companion stamped every agent-authored relay with the git // origin of the MCP server's process.cwd(), on the theory that a stdio MCP // server inherits the host agent's directory, so "where the agent is standing" // is "what the relay is about". // // Those are different things, and conflating them is wrong in the most common // case we actually have. Shane routinely relays us ABOUT relay from a checkout // of a completely different project — his cwd names his repo, the message is // about ours. The cwd passport then either matched nothing on the recipient's // machine (routing fails closed, so the relay would not open at all) or, worse, // named a repo both people happen to have and opened in the wrong one. It also // silently disclosed which project the sender happened to be sitting in. // // So the sender's cwd is no longer consulted. The subject of a relay is // something only the AGENT knows, so the agent states it: relay_send takes an // explicit `repo` argument (see mcp.js), and this module turns whatever the // agent wrote into a portable key. // // WHY NOT A PATH: an absolute path is meaningless to the recipient. The same // logical repo lives at /Users/shane/dev/relay on one Mac, /Users/david/src/relay // on another, and \\wsl.localhost\ubuntu\home\david\src\granular on a third (all // three shapes are attested in the wild). Paths also leak the sender's username // and directory layout to every recipient. Only identity travels. // // The receiving side maps the key through an index built from its OWN local // checkouts and never treats any part of it as a path (see repo-index.js and // cwd-select.js). // Normalize any git remote URL into a stable `host/owner/name` key. // // Collapses the scheme variants that would otherwise split one repo into // several identities — on a single real machine granular appears as BOTH // `git@github.com:owner/granular.git` and `https://github.com/owner/granular.git`. // // SECURITY: HTTPS remotes can embed credentials // (`https://x-access-token:ghp_abc@github.com/owner/repo.git`). Everything // before the last `@` in the authority is dropped, so a token can never ride // out to a recipient inside the origin key. // // Returns "" when the input is not a recognizable remote. export function normalizeOriginKey(originUrl) { const raw = String(originUrl || "").trim(); if (!raw) return ""; let rest = raw; // scp-like syntax has no scheme: git@host:owner/name.git const scpMatch = /^([^/@]+@)?([^/:]+):(?!\/)(.+)$/.exec(rest); if (scpMatch && !/^[a-z][a-z0-9+.-]*:\/\//i.test(rest)) { rest = `${scpMatch[2]}/${scpMatch[3]}`; } else { rest = rest.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); } // Drop userinfo (credentials) — everything up to the LAST @ in the authority. const slash = rest.indexOf("/"); const authority = slash === -1 ? rest : rest.slice(0, slash); const pathPart = slash === -1 ? "" : rest.slice(slash + 1); const at = authority.lastIndexOf("@"); let host = at === -1 ? authority : authority.slice(at + 1); host = host.replace(/:\d+$/, "").toLowerCase(); // strip port if (!host) return ""; const cleanPath = pathPart .replace(/\.git\/?$/i, "") .replace(/^\/+|\/+$/g, "") // Forge URLs are case-insensitive for owner/name; lowercase so two clones // that differ only in case still match. .toLowerCase(); if (!cleanPath) return ""; return `${host}/${cleanPath}`; } // Does this look like a forge HOST rather than an owner? Hosts carry a dot // (github.com, gitlab.example.co.uk, git.internal) or are localhost. Without // this, `owner/name` would normalize to a `host/name` key with the owner // masquerading as the host, and never match a real origin. function looksLikeHost(segment) { const s = String(segment || "").toLowerCase(); return s === "localhost" || (s.includes(".") && !s.startsWith(".") && !s.endsWith(".")); } /** * Turn what an agent WROTE into a workspace passport. * * The agent is asked for "the repo this relay is about" and may reasonably give * any of the forms a human would recognize, so all of them are accepted: * * git@github.com:owner/relay.git → kind "git", key "github.com/owner/relay" * https://github.com/owner/relay → kind "git", key "github.com/owner/relay" * github.com/owner/relay → kind "git", key "github.com/owner/relay" * owner/relay → kind "name", key "owner/relay" * relay → kind "name", key "relay" * * `kind: "git"` is a fully-qualified identity and matches a checkout's origin * exactly. `kind: "name"` is a weaker claim the receiver resolves against its own * index (see repo-index.js) — deliberately supported, because the agent naming * "relay" is a far better signal than the silent cwd guess it replaces, and * because the sender frequently does not know the recipient's forge or owner. * * Returns null for anything unusable, and the relay then carries no passport at * all — which routes exactly as an un-anchored message, not as a wrong guess. */ export function workspacePassportFromDeclaration(declared) { if (declared && typeof declared === "object") { // Tolerate a structured form too, so a caller that already holds an origin // does not have to re-serialize it into a string. const fromObject = declared.originKey || declared.origin || declared.url || declared.repo || declared.name || declared.key; const passport = workspacePassportFromDeclaration(fromObject); if (!passport) return null; const branch = String(declared.branch || "").trim(); return branch ? { ...passport, branch } : passport; } const raw = String(declared || "").trim(); if (!raw) return null; // A path is never a passport: it is meaningless on the recipient's machine and // discloses the sender's layout. Refuse rather than silently mangling it. if (/^[a-zA-Z]:[\\/]/.test(raw) || raw.startsWith("/") || raw.startsWith("~") || raw.startsWith("\\\\")) { return null; } const originKey = normalizeOriginKey(raw); if (originKey) { const [first, ...restSegments] = originKey.split("/"); if (looksLikeHost(first) && restSegments.length >= 1) { return { kind: "git", key: originKey, label: restSegments[restSegments.length - 1] }; } } // Not host-qualified: an `owner/name` or bare `name` claim. const fragment = raw .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "") .replace(/\.git\/?$/i, "") .replace(/^\/+|\/+$/g, "") .toLowerCase(); if (!fragment || /\s/.test(fragment)) return null; const segments = fragment.split("/").filter(Boolean); if (!segments.length || segments.length > 2) return null; return { kind: "name", key: segments.join("/"), label: segments[segments.length - 1] }; }