UNPKG

relay-companion

Version:

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

468 lines (436 loc) • 20.4 kB
import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createRequire } from "node:module"; import atomicJson from "./atomic-json.cjs"; const { atomicWriteJsonSync } = atomicJson; const { writeDeviceToken, readDeviceToken, deleteDeviceToken } = createRequire(import.meta.url)("./credential-store.cjs"); export const NATIVE_CREDENTIAL_STORE = "native-v1"; export const LOCAL_CREDENTIAL_STORE = "local-v2"; export const LEGACY_DEVICE_CREDENTIAL_ACCOUNT = "device-token"; export const CREDENTIAL_STATUS_AVAILABLE = "available"; export const CREDENTIAL_STATUS_UNPAIRED = "unpaired"; export const CREDENTIAL_STATUS_UNAVAILABLE = "unavailable"; export const CREDENTIAL_STATUS_MISSING = "missing"; export const CREDENTIAL_STATUS_CORRUPT = "corrupt"; function localCredentialStorePlatform(platform = process.platform) { return platform === "darwin" || platform === "linux"; } function nativeCredentialAccessAllowed(env = process.env) { // The macOS store follows the selected config directory, so a sandbox stays // inside its sandbox. Windows Credential Manager is machine-global; keep its // explicit opt-in for custom config paths so tests cannot touch real entries. if (localCredentialStorePlatform()) return true; return (!(env.RELAY_CONFIG || env.RELAY_CONFIG_DIR) || env.RELAY_NATIVE_CREDENTIALS_WITH_CUSTOM_CONFIG === "1"); } const nativeCredentialBackend = { writeDeviceToken: (token, options) => nativeCredentialAccessAllowed() ? writeDeviceToken(token, options) : { ok: false, detail: "native credentials disabled for custom config path" }, readDeviceToken: (options) => nativeCredentialAccessAllowed() ? readDeviceToken(options) : { ok: false, value: "", detail: "native credentials disabled for custom config path" }, deleteDeviceToken: (options) => nativeCredentialAccessAllowed() ? deleteDeviceToken(options) : { ok: false, value: "", detail: "native credentials disabled for custom config path" }, }; function credentialStoreKind() { return localCredentialStorePlatform() ? LOCAL_CREDENTIAL_STORE : NATIVE_CREDENTIAL_STORE; } function usesCredentialStore(value) { return value === NATIVE_CREDENTIAL_STORE || value === LOCAL_CREDENTIAL_STORE; } export const DEFAULT_API_URL = "https://api.sendrelays.com"; export const DEFAULT_WEB_URL = "https://sendrelays.com"; // The dev API (DEPLOY.md "Dev backend"): same accounts, same inboxes, newer // server code — the RelayDevApi App Runner service, deployed 2026-08-14. // `relay env dev` flips a machine here with no flags. export const DEFAULT_DEV_API_URL = "https://dev-api.sendrelays.com"; // Staging now has a retained App Runner API. Account activation still belongs // to sendrelays.com: the raw staging/dev web services cannot initialize the // production Clerk tenant. export const DEFAULT_STAGING_API_URL = "https://cti37jd7vx.us-east-1.awsapprunner.com"; export const AUTH_INCOMPATIBLE_WEB_URLS = [ "https://ujvrds7yxv.us-east-1.awsapprunner.com", "https://8epdrqim29.us-east-1.awsapprunner.com", ]; /** * Origins the fleet was pointed at before api.sendrelays.com existed. Every * install from that era persisted the raw App Runner hostname into config.json * at pairing, and the stored value outranks DEFAULT_API_URL forever — so * changing the default reached nobody already paired. Worse, that hostname is * AWS-generated: recreate the service and it changes, stranding every such * device with no way to receive the fix (auto-update reaches the API through * this same URL). * * apiUrl() therefore maps a stored legacy origin to the canonical one at read * time (correct immediately, even on a read-only disk) and heals the stored * value once per process (correct durably, so the pill and future processes * read a healthy file). The legacy host must stay routable until the * `legacyApiHost` counter on relay-api's /health shows the fleet has stopped * dialing it — retiring it earlier orphans every device this code has not * reached yet. */ export const LEGACY_API_URLS = ["https://aia6vj5pgp.us-east-1.awsapprunner.com"]; const LEGACY_API_ORIGINS = new Set(LEGACY_API_URLS.map((url) => normalizeOrigin(url))); function normalizeOrigin(value) { return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); } /** Map a stored legacy API origin to the canonical URL; any other value passes through. */ export function canonicalizeApiUrl(value) { if (!value) return value; return LEGACY_API_ORIGINS.has(normalizeOrigin(value)) ? DEFAULT_API_URL : value; } /** * Release channels. "stable" follows npm's `latest` dist-tag (every user's * default); "dev" and "staging" follow their matching dist-tags, each advanced * only by a deliberate promotion of an immutable build. Staging is a * production-like release candidate: selecting its update channel must never * enable developer-only product capabilities. * Unknown stored values normalize to stable so a corrupt or future config value * can never strand a machine on an unknown tag. */ export const UPDATE_CHANNEL_STABLE = "stable"; export const UPDATE_CHANNEL_DEV = "dev"; export const UPDATE_CHANNEL_STAGING = "staging"; export function normalizeUpdateChannel(value) { const normalized = String(value || "").trim().toLowerCase(); if (normalized === UPDATE_CHANNEL_DEV) return UPDATE_CHANNEL_DEV; if (normalized === UPDATE_CHANNEL_STAGING) return UPDATE_CHANNEL_STAGING; return UPDATE_CHANNEL_STABLE; } export function updateChannel() { return normalizeUpdateChannel(process.env.RELAY_UPDATE_CHANNEL || readConfig().updateChannel); } /** * Companion configuration lives in ~/.relay/config.json. New macOS/Linux/Windows * installs keep only non-secret account metadata here; the device token lives in * Relay's owner-only local store on macOS/Linux or Credential Manager on Windows. * Legacy plaintext and macOS Keychain tokens migrate on first read. * Environment variables override the file for local/staging testing. */ export function configDir() { return process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay"); } export function configPath() { // RELAY_CONFIG (a full file path) matches the overlay's readConfigFile // resolution, so a sandboxed pill and these helpers agree on ONE file. return process.env.RELAY_CONFIG || path.join(configDir(), "config.json"); } function readConfigRawState() { const file = configPath(); let raw; try { raw = fs.readFileSync(file, "utf8"); } catch (error) { if (error?.code === "ENOENT") return { config: {}, status: "absent" }; return { config: {}, status: "unavailable", code: "config_unavailable" }; } try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid config shape"); return { config: parsed, status: "ok" }; } catch (error) { // The file exists but is corrupt (crash mid-write on the old non-atomic path). // Preserve it for inspection instead of silently treating the device as unpaired // and letting the daemon crash-loop — but do NOT delete: a paired token may be // partially recoverable. One backup per process: the daemon now re-reads this // file every poll (accountIdentity), and a corrupt file must not become a // new backup every 4 seconds. if (!backedUpCorruptConfig) { backedUpCorruptConfig = true; try { const backup = `${file}.corrupt-${Date.now()}`; fs.copyFileSync(file, backup); // eslint-disable-next-line no-console console.error(`[relay] config.json is corrupt; backed up to ${backup}`); } catch {} } return { config: {}, status: "corrupt", code: "config_corrupt" }; } } function readConfigRaw() { return readConfigRawState().config; } let backedUpCorruptConfig = false; let cachedCredentialVersion = null; let cachedDeviceToken = ""; function newCredentialIdentity() { const credentialVersion = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`; return { credentialVersion, credentialAccount: `${LEGACY_DEVICE_CREDENTIAL_ACCOUNT}-${credentialVersion}` }; } function credentialAccountFor(config) { return String(config?.credentialAccount || LEGACY_DEVICE_CREDENTIAL_ACCOUNT); } /** * Hydrate the device credential from protected storage. A legacy plaintext * token migrates only after the protected write succeeds; if either the * credential store or the atomic config rewrite fails, the plaintext remains as a * rollback-safe fallback and the user is never forced to authenticate again. */ export function readConfigState({ credentialBackend = nativeCredentialBackend } = {}) { const rawState = readConfigRawState(); const raw = rawState.config; if (process.env.RELAY_DEVICE_TOKEN) { return { config: raw, credential: { status: CREDENTIAL_STATUS_AVAILABLE } }; } if (rawState.status === "unavailable") { return { config: raw, credential: { status: CREDENTIAL_STATUS_UNAVAILABLE, code: rawState.code }, }; } if (rawState.status === "corrupt") { return { config: raw, credential: { status: CREDENTIAL_STATUS_CORRUPT, code: rawState.code }, }; } if (raw.deviceToken) { const identity = newCredentialIdentity(); const stored = credentialBackend.writeDeviceToken(raw.deviceToken, { account: identity.credentialAccount }); if (!stored.ok) { return { config: raw, credential: { status: CREDENTIAL_STATUS_AVAILABLE } }; } const next = { ...raw, credentialStore: credentialStoreKind(), ...identity }; const token = next.deviceToken; delete next.deviceToken; try { atomicWriteJsonSync(configPath(), withoutDeprecatedCapabilityConfig(next), { mode: 0o600 }); cachedCredentialVersion = next.credentialVersion; cachedDeviceToken = token; return { config: { ...next, deviceToken: token }, credential: { status: CREDENTIAL_STATUS_AVAILABLE }, }; } catch { try { credentialBackend.deleteDeviceToken?.({ account: identity.credentialAccount }); } catch {} return { config: raw, credential: { status: CREDENTIAL_STATUS_AVAILABLE } }; } } if (usesCredentialStore(raw.credentialStore)) { const credentialVersion = raw.credentialVersion || "native"; if (credentialVersion === cachedCredentialVersion && cachedDeviceToken) { return { config: { ...raw, deviceToken: cachedDeviceToken }, credential: { status: CREDENTIAL_STATUS_AVAILABLE }, }; } const stored = credentialBackend.readDeviceToken({ account: credentialAccountFor(raw), allowLegacyMigration: raw.credentialStore === NATIVE_CREDENTIAL_STORE, }); if (stored.ok && stored.value) { const migratedPointer = localCredentialStorePlatform() && raw.credentialStore === NATIVE_CREDENTIAL_STORE ? { ...raw, credentialStore: LOCAL_CREDENTIAL_STORE } : raw; if (migratedPointer !== raw) { try { atomicWriteJsonSync(configPath(), withoutDeprecatedCapabilityConfig(migratedPointer), { mode: 0o600 }); } catch {} } cachedCredentialVersion = credentialVersion; cachedDeviceToken = stored.value; return { config: { ...migratedPointer, deviceToken: stored.value }, credential: { status: CREDENTIAL_STATUS_AVAILABLE }, }; } if (stored.ok) { return { config: raw, credential: { status: CREDENTIAL_STATUS_CORRUPT, code: "credential_empty" }, }; } if (localCredentialStorePlatform() && raw.credentialStore === NATIVE_CREDENTIAL_STORE) { // The bridge installer deliberately remains npm's `latest` catch-up rail. // Older bridge builds wrote a Keychain pointer that current macOS builds // no longer use. A failed one-time legacy read must therefore lead to a // clean reconnect, not an unrecoverable retry loop. const healed = { ...raw, credentialStore: LOCAL_CREDENTIAL_STORE }; try { atomicWriteJsonSync(configPath(), withoutDeprecatedCapabilityConfig(healed), { mode: 0o600 }); } catch {} return { config: healed, credential: { status: CREDENTIAL_STATUS_MISSING, code: stored.code || "legacy_credential_unavailable", }, }; } const missing = stored.code === "credential_not_found"; return { config: raw, credential: { status: missing ? CREDENTIAL_STATUS_MISSING : CREDENTIAL_STATUS_UNAVAILABLE, code: stored.code || "credential_store_error", }, }; } return { config: raw, credential: { status: CREDENTIAL_STATUS_UNPAIRED } }; } export function readConfig(options) { return readConfigState(options).config; } /** * Replace config.json with exactly `next` (no merge). This is the primitive that * lets sign-out REMOVE keys — writeConfig's patch merge can only add or overwrite. */ export function writeConfigObject( next, { credentialBackend = nativeCredentialBackend, requireNativeCredential = false, atomicWrite = atomicWriteJsonSync } = {}, ) { const file = configPath(); const cleaned = withoutDeprecatedCapabilityConfig(next); const previous = readConfigRaw(); const previousCredentialAccount = usesCredentialStore(previous.credentialStore) ? credentialAccountFor(previous) : ""; let storedNativeCredential = false; let nextCredentialAccount = ""; let tokenForCache = ""; if (cleaned.deviceToken) { const token = String(cleaned.deviceToken); const identity = newCredentialIdentity(); const stored = credentialBackend.writeDeviceToken(token, { account: identity.credentialAccount }); if (stored.ok) { storedNativeCredential = true; nextCredentialAccount = identity.credentialAccount; tokenForCache = token; delete cleaned.deviceToken; cleaned.credentialStore = credentialStoreKind(); cleaned.credentialVersion = identity.credentialVersion; cleaned.credentialAccount = identity.credentialAccount; } else { if (requireNativeCredential) { throw new Error(`Could not store Relay's device credential securely (${stored.detail || "credential store unavailable"}).`); } delete cleaned.credentialStore; delete cleaned.credentialVersion; delete cleaned.credentialAccount; } } // Atomic write: a crash mid-write must not truncate config.json and wipe the // device token. Windows rename retries cover short-lived scanner/read locks. try { atomicWrite(file, cleaned, { mode: 0o600 }); } catch (error) { // A first-time authorization must never leave a credential active without // matching account metadata. Best-effort rollback keeps the previous // config authoritative if its atomic replacement failed. if (storedNativeCredential) { try { credentialBackend.deleteDeviceToken?.({ account: nextCredentialAccount }); } catch {} } throw error; } if (storedNativeCredential) { cachedCredentialVersion = cleaned.credentialVersion; cachedDeviceToken = tokenForCache; // The config commit is the credential pointer commit. Only now can the old // vault target be deleted; a failure leaves a harmless orphan, never a // missing credential behind the still-authoritative old config. if (previousCredentialAccount && previousCredentialAccount !== nextCredentialAccount) { try { credentialBackend.deleteDeviceToken?.({ account: previousCredentialAccount }); } catch {} } } return cleaned; } export function withoutDeprecatedCapabilityConfig(next) { // Task capability is server-owned. Remove the local switches older // companions persisted whenever we next touch the config so stale settings // cannot look authoritative to operators or future code. const cleaned = { ...next }; delete cleaned.companionMode; if (cleaned.features && typeof cleaned.features === "object") { const features = { ...cleaned.features }; delete features.requests; if (Object.keys(features).length) cleaned.features = features; else delete cleaned.features; } return cleaned; } export function writeConfig(patch) { return writeConfigObject({ ...readConfig(), ...patch }); } // One heal attempt per process: on a read-only or failing disk, retrying the // write on every apiUrl() call would add mkdir+rename to each daemon poll for // no benefit — the read-time mapping below already keeps the process correct. const attemptedApiUrlHeals = new Set(); export function apiUrl() { // An explicit env override is intentional (tests, staging, a local API) and // is never rewritten — even when it names the legacy host. if (process.env.RELAY_API_URL) return process.env.RELAY_API_URL; const config = readConfig(); const stored = config.apiUrl; const canonical = canonicalizeApiUrl(stored) || DEFAULT_API_URL; // A Companion on the dev release channel must talk to the dev API. Older // setup instructions changed only the npm channel, leaving the persisted API // at production. That produced a Dev Slack UI whose server returned a raw // route_not_found. Repair only the known incoherent default/legacy pair; // explicit local and staging origins remain valid developer choices. const devChannel = normalizeUpdateChannel(process.env.RELAY_UPDATE_CHANNEL || config.updateChannel) === UPDATE_CHANNEL_DEV; const resolved = devChannel && canonical === DEFAULT_API_URL ? DEFAULT_DEV_API_URL : canonical; const healKey = `${configPath()}:${stored || "<default>"}->${resolved}`; if (resolved !== (stored || DEFAULT_API_URL) && !attemptedApiUrlHeals.has(healKey)) { attemptedApiUrlHeals.add(healKey); try { writeConfig({ apiUrl: resolved, ...(resolved === DEFAULT_DEV_API_URL ? { devApiUrl: resolved } : {}), }); } catch { // The file keeps the incoherent value; this process still uses the canonical // URL, and the next process start retries the heal. } } return resolved; } const attemptedWebUrlHeals = new Set(); export function webUrl() { if (process.env.RELAY_WEB_URL) return process.env.RELAY_WEB_URL.replace(/\/+$/, ""); const config = readConfig(); const stored = String(config.webUrl || DEFAULT_WEB_URL).replace(/\/+$/, ""); const incompatibleOrigins = new Set(AUTH_INCOMPATIBLE_WEB_URLS.map((url) => normalizeOrigin(url))); // 0.1.439 persisted the raw dev/staging reader as the account origin. Those // services can render pages but production Clerk rejects their hostnames, so // setup can never leave its loading state. Heal only those known bad values; // explicit localhost and custom test origins remain authoritative. const resolved = incompatibleOrigins.has(normalizeOrigin(stored)) ? DEFAULT_WEB_URL : stored; const healKey = `${configPath()}:${stored}->${resolved}`; if (resolved !== stored && !attemptedWebUrlHeals.has(healKey)) { attemptedWebUrlHeals.add(healKey); try { writeConfig({ webUrl: resolved }); } catch { // The current process still uses the Clerk-capable origin; a later process can // retry the durable heal if this disk write was unavailable. } } return resolved; } export function deviceToken() { return process.env.RELAY_DEVICE_TOKEN || readConfig().deviceToken || ""; } export function currentUser() { return readConfig().user || null; } /** * The account a process is (or would be) authenticated as RIGHT NOW: who * config.json names, and the token it holds. Long-lived processes bind an * instance of this at startup and compare against a fresh read later, so a * pairing, sign-out, or account switch that lands while they run cannot leave * them silently serving the previous person. Honours the RELAY_DEVICE_TOKEN * override the same way deviceToken() does. */ export function accountIdentity(config = readConfig()) { const user = (config && config.user) || null; return { userId: String((user && user.id) || ""), email: String((user && user.email) || ""), deviceId: String((config && config.deviceId) || ""), deviceToken: process.env.RELAY_DEVICE_TOKEN || String((config && config.deviceToken) || ""), }; } /** Durable local runtime ledger for agent task sessions. */ export function taskLedgerPath() { return path.join(configDir(), "task-ledger.json"); }