UNPKG

relay-companion

Version:

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

214 lines (199 loc) • 9 kB
// Which directory should a relay open in? // // Pure decision module, deliberately shaped like overlay/host-select.cjs: no // filesystem, process, or network access of its own — every effect is injected. // That keeps the routing rules unit-testable without mocking a machine. // // ARCHITECTURE: // // Agent-authored relays carry a project/workspace passport naming the repo the // relay is ABOUT — stated by the sending agent, not inferred from its cwd: // source.workspace = { kind: "git", key: "github.com/owner/repo", ... } // source.workspace = { kind: "name", key: "repo", ... } // // The receiver maps that passport through an index built from THIS machine's // local repos. If it maps, the relay opens there. If the sender did not stamp a // passport, the product caller may deliberately route it to this receiver's // default Relay home: RELAY_OPEN_CWD when configured, else a dedicated // ~/Relay folder created on first use so every relay lands in a stable, // recognizable place instead of the bare home directory. A passport that // names an unknown repo still fails closed: it never degrades to a guessed // directory. // // TRUST MODEL: // // A relay's `source` is written by the SENDER and is untrusted input. A // sender-supplied PATH is never opened, never resolved, never stat'd. Only the // portable workspace identity is used, and only ever as a key looked up in an // index this machine built from its own local state. The worst a hostile sender // can do is name a repo the recipient already has and works in. import os from "node:os"; import fs from "node:fs"; import path from "node:path"; function defaultIsDirectory(candidate) { try { return fs.statSync(candidate).isDirectory(); } catch { return false; } } function defaultEnsureDirectory(candidate) { try { fs.mkdirSync(candidate, { recursive: true }); return fs.statSync(candidate).isDirectory(); } catch { return false; } } // Codex hard-fails and Claude silently forges a dead session when the working // directory does not exist, so a candidate is only usable if it is really there. function usable(candidate, isDirectory) { const clean = String(candidate || "").trim(); if (!clean) return ""; const resolved = path.resolve(clean); return isDirectory(resolved) ? resolved : ""; } function legacyWorkspaceFromRepo(repo) { if (!repo || typeof repo !== "object") return null; const originKey = String(repo.originKey || "").trim().toLowerCase(); const rootCommit = String(repo.rootCommit || "").trim(); const key = originKey || (rootCommit ? `git-root:${rootCommit}` : ""); if (!key) return null; return { kind: "git", key, ...(repo.name ? { label: String(repo.name) } : {}), ...(repo.branch ? { branch: String(repo.branch) } : {}), ...(rootCommit ? { rootCommit } : {}), }; } function workspaceFromRow(row) { const source = row?.source && typeof row.source === "object" ? row.source : null; const workspace = source?.workspace && typeof source.workspace === "object" ? source.workspace : null; if (workspace) { const kind = String(workspace.kind || "").trim(); const key = String(workspace.key || "").trim().toLowerCase(); if ((kind === "git" || kind === "name") && key) { return { kind, key, ...(workspace.label ? { label: String(workspace.label) } : {}), ...(workspace.branch ? { branch: String(workspace.branch) } : {}), ...(workspace.rootCommit ? { rootCommit: String(workspace.rootCommit) } : {}), }; } } // Backwards compatibility for senders still stamping the old cwd-derived // source.repo (0.1.117 onwards). Treat it as a passport-shaped identity, not as // one rung in a fallback ladder. Such a passport names wherever that agent // happened to be standing, so it is right only by luck — but it is still an // identity looked up in this machine's own index, so it can only ever resolve // to a repo the recipient already has. return legacyWorkspaceFromRepo(source?.repo); } function workspaceKey(workspace) { if (!workspace) return ""; return `${workspace.kind}:${workspace.key}`; } function repoContextFromWorkspace(workspace) { if (!workspace) return null; const key = String(workspace.key || "").trim().toLowerCase(); if (!key) return null; const branch = workspace.branch ? { branch: String(workspace.branch) } : {}; // A name claim carries no origin and no commit — it is resolved against the // receiver's index by name only (see repo-index.js matchesNameClaim). if (workspace.kind === "name") return { nameClaim: key, ...branch }; if (workspace.kind !== "git") return null; const fromRootCommitKey = key.startsWith("git-root:") ? key.slice("git-root:".length) : ""; return { ...(fromRootCommitKey ? {} : { originKey: key }), rootCommit: String(workspace.rootCommit || fromRootCommitKey || "").trim(), // An origin that finds no local checkout still falls back to the repo's own // name: the sender may be naming the same project under a different fork or // forge than the one the recipient cloned from. ...(fromRootCommitKey ? {} : { nameClaim: key.split("/").pop() || "" }), ...branch, }; } // Unanchored relays (human-typed messages, task opens — anything without a // workspace passport) all land in one dedicated Relay folder under home, // created on first use, so their sessions file under a recognizable "Relay" // project in Claude Code and carry a recognizable cwd in Codex. Home itself is // only the last resort for a machine where that folder cannot be created. function unanchoredFallback({ env, homedir, isDirectory, ensureDirectory }) { const envCwd = usable(env.RELAY_OPEN_CWD, isDirectory); if (envCwd) return { cwd: envCwd, reason: "env" }; const relayFolder = path.resolve(path.join(homedir, "Relay")); if (isDirectory(relayFolder) || ensureDirectory(relayFolder)) { return { cwd: relayFolder, reason: "relay-folder" }; } return { cwd: homedir, reason: "home" }; } // Resolve the working directory for one relay open. // // Returns { cwd, reason, repoName, workspaceKey, openable }. `reason` names the // architectural decision that won — it is logged and persisted so a surprising // routing decision is diagnosable after the fact. export function chooseOpenCwd({ requestedCwd = "", row = null, rowState = null, findCheckoutsFn = null, env = process.env, homedir = os.homedir(), isDirectory = defaultIsDirectory, ensureDirectory = defaultEnsureDirectory, allowUnanchoredFallback = false, } = {}) { const workspace = workspaceFromRow(row); const key = workspaceKey(workspace); const repoName = String(workspace?.label || row?.source?.repo?.name || "").trim(); // A human/operator can deliberately override the routing for this one open. // This is explicit, local control — not a sender hint and not a guess. const explicit = usable(requestedCwd, isDirectory); if (explicit) return { cwd: explicit, reason: "explicit", repoName, workspaceKey: key, openable: true }; // Re-opening an already forged row can reuse its native-session cwd, but never // let the old "home" bug become sticky. When the row carries a passport, a // remembered cwd must either be from the same passport or be old state from a // successful non-home route. const remembered = usable(rowState?.openCwd, isDirectory); const rememberedReason = String(rowState?.openCwdReason || "").trim(); const rememberedWorkspaceKey = String(rowState?.openWorkspaceKey || "").trim(); if ( remembered && rememberedReason && rememberedReason !== "home" && (!key || !rememberedWorkspaceKey || rememberedWorkspaceKey === key) ) { return { cwd: remembered, reason: "remembered", repoName, workspaceKey: rememberedWorkspaceKey || key, openable: true }; } if (!workspace) { if (allowUnanchoredFallback) { const fallback = unanchoredFallback({ env, homedir, isDirectory, ensureDirectory }); return { ...fallback, repoName, workspaceKey: "", openable: Boolean(fallback.cwd) }; } return { cwd: "", reason: "missing-workspace-passport", repoName, workspaceKey: "", openable: false }; } const repoContext = repoContextFromWorkspace(workspace); if (repoContext && typeof findCheckoutsFn === "function") { let checkouts = []; try { checkouts = findCheckoutsFn(repoContext) || []; } catch { checkouts = []; } for (const checkout of checkouts) { const dir = usable(checkout?.dir, isDirectory); if (dir) { return { cwd: dir, reason: checkouts.length > 1 ? "workspace-passport-ranked" : "workspace-passport", repoName: repoName || checkout.originKey || workspace.key, workspaceKey: key, openable: true, }; } } } return { cwd: "", reason: "workspace-unmapped", repoName, workspaceKey: key, openable: false }; }