UNPKG

relay-companion

Version:

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

732 lines (681 loc) • 29 kB
import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import atomicJson from "./atomic-json.cjs"; import { persistPairedAccount } from "./account.js"; import { apiUrl, configPath, CREDENTIAL_STATUS_CORRUPT, CREDENTIAL_STATUS_MISSING, CREDENTIAL_STATUS_UNAVAILABLE, DEFAULT_WEB_URL, readConfigState, } from "./config.js"; const { atomicWriteJsonSync } = atomicJson; const { writeCredential, readCredential, deleteCredential } = createRequire(import.meta.url)("./credential-store.cjs"); export const INSTALLATION_AUTHORIZATION_FILE = "installation-authorization.json"; export const INSTALLATION_CREDENTIAL_SERVICE = "work.relay.companion.installation"; export const INSTALLATION_CREDENTIAL_ACCOUNT = "authorization"; export const INSTALLATION_CREDENTIAL_PROBE_ACCOUNT = "availability-probe"; const INSTALLATION_CREDENTIAL_ACCOUNTS = Object.freeze({ authorizationId: "authorization-id", clientSecret: "client-secret", codeVerifier: "code-verifier", activationToken: "activation-token", }); const AUTHORIZATION_STATUSES = new Set([ "pending_identity", "pending_approval", "approved", "consumed", "expired", ]); const RESUMABLE_STATUSES = new Set(["pending_identity", "pending_approval", "approved", "consumed"]); const REQUEST_TIMEOUT_MS = 20_000; function statePath() { return path.join(path.dirname(configPath()), INSTALLATION_AUTHORIZATION_FILE); } function defaultStateStore(file = statePath()) { return { read() { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { if (error?.code === "ENOENT") return null; throw new Error("Relay setup state could not be read. Cancel setup and start again."); } }, write(value) { atomicWriteJsonSync(file, value, { mode: 0o600 }); }, remove() { try { fs.rmSync(file, { force: true }); } catch (error) { throw new Error(`Relay setup state could not be removed (${error?.code || "filesystem error"}).`); } }, }; } export function createNativeInstallationSecretStore({ webBase = DEFAULT_WEB_URL, service = INSTALLATION_CREDENTIAL_SERVICE, credentialOptions = {}, writeCredentialImpl = writeCredential, readCredentialImpl = readCredential, deleteCredentialImpl = deleteCredential, } = {}) { const options = (account) => ({ ...credentialOptions, service, account }); const accounts = Object.values(INSTALLATION_CREDENTIAL_ACCOUNTS); const sentinelAccount = INSTALLATION_CREDENTIAL_ACCOUNTS.authorizationId; const removeSentinelLast = (targetAccounts) => { const ordered = [ ...targetAccounts.filter((account) => account !== sentinelAccount), ...(targetAccounts.includes(sentinelAccount) ? [sentinelAccount] : []), ]; for (const account of ordered) { const result = deleteCredentialImpl(options(account)); // The sentinel must remain whenever an earlier field could not be // removed, so a later Begin still detects the interrupted capability. if (!result?.ok) return result; } return { ok: true, value: "", detail: "" }; }; const removeAccounts = () => removeSentinelLast([...accounts, INSTALLATION_CREDENTIAL_ACCOUNT]); return { inspect() { // authorization-id is written first and rolled back last, so it is the // bounded sentinel for a complete or interrupted split-field commit. const current = readCredentialImpl(options(INSTALLATION_CREDENTIAL_ACCOUNTS.authorizationId)); if (current?.ok) return { ok: true, present: true, value: "", detail: "" }; if (current?.code !== "credential_not_found") return current; // Do not inspect the legacy envelope here. Without matching durable // state it is unusable and harmless, while touching it on a fresh start // would add a second Windows vault read. Durable legacy setup uses // read() below for its one migration. return { ok: true, present: false, value: "", detail: "" }; }, probe() { const probe = randomBytes(24).toString("base64url"); const target = options(INSTALLATION_CREDENTIAL_PROBE_ACCOUNT); const written = writeCredentialImpl(probe, target); if (!written?.ok) return written; const verified = readCredentialImpl(target); const removed = deleteCredentialImpl(target); if (!verified?.ok) return verified; if (verified.value !== probe) { return { ok: false, value: "", detail: "credential verification failed", code: "credential_verification_failed" }; } return removed?.ok ? { ok: true, value: "", detail: "" } : removed; }, write(value) { let activationUrl; try { activationUrl = new URL(String(value?.activationUrl || "")); } catch { return { ok: false, detail: "invalid activation URL" }; } const activationToken = new URLSearchParams(activationUrl.hash.slice(1)).get("activationToken") || ""; const fields = { authorizationId: String(value?.authorizationId || ""), clientSecret: String(value?.clientSecret || ""), codeVerifier: String(value?.codeVerifier || ""), activationToken, }; if (Object.values(fields).some((field) => !field)) { return { ok: false, detail: "installation authorization is incomplete" }; } const written = []; for (const [field, account] of Object.entries(INSTALLATION_CREDENTIAL_ACCOUNTS)) { const result = writeCredentialImpl(fields[field], options(account)); if (!result?.ok) { removeSentinelLast(written); return result; } written.push(account); } deleteCredentialImpl(options(INSTALLATION_CREDENTIAL_ACCOUNT)); return { ok: true, value: "", detail: "" }; }, read() { const values = {}; let missing = null; for (const [field, account] of Object.entries(INSTALLATION_CREDENTIAL_ACCOUNTS)) { // These split fields have only ever existed in the local-v2 store. Do // not probe the legacy login Keychain for accounts no released build // ever wrote there. The one old envelope is handled once below. const result = readCredentialImpl(options(account)); if (!result?.ok) { missing = result; break; } values[field] = result.value; } if (!missing) { const target = new URL(`/activate/${encodeURIComponent(values.authorizationId)}`, normalizeWebOrigin(webBase)); target.hash = new URLSearchParams({ activationToken: values.activationToken }).toString(); return { ok: true, value: { authorizationId: values.authorizationId, clientSecret: values.clientSecret, codeVerifier: values.codeVerifier, activationUrl: target.toString(), } }; } const legacy = readCredentialImpl({ ...options(INSTALLATION_CREDENTIAL_ACCOUNT), allowLegacyMigration: true }); if (!legacy.ok) return missing; try { return { ok: true, value: JSON.parse(legacy.value) }; } catch { return { ok: false, detail: "stored authorization is invalid" }; } }, delete() { return removeAccounts(); }, }; } /** Remove every protected secret belonging to an unfinished or completed setup. */ export function deleteInstallationAuthorizationCredentials({ platform = process.platform, env = process.env, } = {}) { return createNativeInstallationSecretStore({ credentialOptions: { platform, env }, }).delete(); } function validDate(value) { const millis = Date.parse(String(value || "")); return Number.isFinite(millis) ? new Date(millis).toISOString() : ""; } function accountSummary(value) { if (!value || typeof value !== "object") return undefined; const email = String(value.email || "").trim(); const displayName = String(value.displayName || value.name || email).trim(); if (!email || !displayName) return undefined; return { email, displayName }; } function validateDurableState(value) { if (!value || typeof value !== "object") return null; const authorizationId = String(value.authorizationId || ""); const expiresAt = validDate(value.expiresAt); const status = String(value.status || ""); if (!/^[A-Za-z0-9_-]{8,200}$/.test(authorizationId) || !expiresAt || !AUTHORIZATION_STATUSES.has(status)) { throw new Error("Relay setup state is invalid. Cancel setup and start again."); } return { schemaVersion: 1, authorizationId, expiresAt, status, ...(accountSummary(value.account) ? { account: accountSummary(value.account) } : {}), }; } function validateSecret(value, authorizationId, trustedWebOrigin) { if (!value || typeof value !== "object") throw new Error("Relay setup authorization is unavailable. Cancel setup and start again."); const secret = { authorizationId: String(value.authorizationId || ""), clientSecret: String(value.clientSecret || ""), codeVerifier: String(value.codeVerifier || ""), activationUrl: String(value.activationUrl || ""), }; if ( secret.authorizationId !== authorizationId || secret.clientSecret.length < 32 || !/^[A-Za-z0-9._~-]{43,128}$/.test(secret.codeVerifier) ) { throw new Error("Relay setup authorization is unavailable. Cancel setup and start again."); } let activationUrl; try { activationUrl = new URL(secret.activationUrl); } catch { throw new Error("Relay setup authorization is unavailable. Cancel setup and start again."); } const expectedPath = `/activate/${encodeURIComponent(authorizationId)}`; const activationFragment = new URLSearchParams(activationUrl.hash.slice(1)); if ( activationUrl.origin !== trustedWebOrigin || activationUrl.pathname !== expectedPath || !activationFragment.get("activationToken") ) { throw new Error("Relay refused an untrusted setup destination."); } return { ...secret, activationUrl: activationUrl.toString() }; } function publicState(state) { if (!state) return { status: "idle" }; return { status: state.status, expiresAt: state.expiresAt, ...(accountSummary(state.account) ? { account: accountSummary(state.account) } : {}), }; } function pkcePair() { const codeVerifier = randomBytes(32).toString("base64url"); const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); return { codeVerifier, codeChallenge }; } class InstallationRequestError extends Error { constructor(code, status) { super(`Relay setup request failed (${code || `HTTP ${status}`}).`); this.code = code || "request_failed"; this.status = status; } } function credentialError(message, code = "credential_store_error") { const error = new Error(message); error.code = code; return error; } function defaultIsPaired() { const { config, credential } = readConfigState(); if ([CREDENTIAL_STATUS_UNAVAILABLE, CREDENTIAL_STATUS_MISSING, CREDENTIAL_STATUS_CORRUPT].includes(credential.status)) { throw credentialError( "Relay cannot access this computer's saved account credential. Recover the account credential before starting setup.", credential.code || `credential_${credential.status}`, ); } return Boolean(process.env.RELAY_DEVICE_TOKEN || config.deviceToken); } async function postJson(fetchImpl, base, route, body, timeoutMs = REQUEST_TIMEOUT_MS) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); let response; try { response = await fetchImpl(`${base}${route}`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(body), signal: controller.signal, }); } catch (error) { if (error?.name === "AbortError") throw new InstallationRequestError("request_timeout", 0); throw new InstallationRequestError("network_unavailable", 0); } finally { clearTimeout(timer); } let payload = null; try { payload = await response.json(); } catch {} if (!response.ok) { const code = typeof payload?.error === "string" && /^[a-z0-9_]{1,80}$/i.test(payload.error) ? payload.error : "request_failed"; throw new InstallationRequestError(code, response.status); } if (!payload || typeof payload !== "object") throw new InstallationRequestError("invalid_response", response.status); return payload; } function normalizeApiBase(value) { const parsed = new URL(String(value || "")); const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; if (!(parsed.protocol === "https:" || (parsed.protocol === "http:" && local))) { throw new Error("Relay setup requires a secure API endpoint."); } return parsed.toString().replace(/\/$/, ""); } function normalizeWebOrigin(value) { const parsed = new URL(String(value || "")); const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; if (!(parsed.protocol === "https:" || (parsed.protocol === "http:" && local))) { throw new Error("Relay setup requires a secure web endpoint."); } return parsed.origin; } export function createInstallationAuthorizationController({ apiBase = apiUrl(), webBase = DEFAULT_WEB_URL, platform = process.platform, deviceName = os.hostname(), fetchImpl = globalThis.fetch, openExternal = async () => false, durableStore = defaultStateStore(), secretStore = createNativeInstallationSecretStore({ webBase }), now = () => Date.now(), isPaired = defaultIsPaired, persistAccount = (registration) => persistPairedAccount({ apiUrl: normalizeApiBase(apiBase), webUrl: webBase, deviceName, registration, requireNativeCredential: true, }), onConnected = async () => {}, } = {}) { const base = normalizeApiBase(apiBase); const trustedWebOrigin = normalizeWebOrigin(webBase); if (!fetchImpl) throw new Error("Relay setup requires HTTPS support."); if (!["darwin", "win32", "linux"].includes(platform)) { throw new Error("Relay setup supports macOS, Windows, and Linux."); } let beginInFlight = null; let consumeInFlight = null; let resumeInFlight = null; let operationQueue = Promise.resolve(); // Authorization actions cross three commit boundaries (server capability, // protected secret storage, and durable public state). Keep those actions in // one local order so a poll cannot rewrite state after Cancel/Restart and a // second click cannot delete the capability while consume is persisting the // paired account. function serialize(operation) { const result = operationQueue.then(operation, operation); operationQueue = result.catch(() => {}); return result; } async function readDurable() { return validateDurableState(await durableStore.read()); } async function readSecret(state) { const result = await secretStore.read(); if (!result?.ok) throw new Error("Relay setup authorization is unavailable. Cancel setup and start again."); return validateSecret(result.value, state.authorizationId, trustedWebOrigin); } async function removeAuthorization() { const removed = await secretStore.delete(); if (!removed?.ok) { throw new Error("Relay could not remove the one-time setup authorization from protected storage."); } await durableStore.remove(); } async function expire(state) { const expired = { ...state, status: "expired" }; await durableStore.write(expired); // Keep the public tombstone after deleting the one-time secret. It is what // makes replacement an explicit Restart even across a renderer/process // restart; it contains no capability material. await secretStore.delete(); return publicState(expired); } async function activeContext({ create = false } = {}) { let state = await readDurable(); if (state && Date.parse(state.expiresAt) <= now()) { await expire(state); throw new InstallationRequestError("authorization_expired", 410); } if (!state && create) { await beginInternal(); state = await readDurable(); } if (!state) throw new Error("Start Relay account setup first."); if (state.status === "expired") throw new InstallationRequestError("authorization_expired", 410); return { state, secret: await readSecret(state) }; } async function beginInternal() { if (await isPaired()) throw new Error("Relay is already connected on this computer."); const existing = await readDurable(); if (existing && Date.parse(existing.expiresAt) > now() && RESUMABLE_STATUSES.has(existing.status)) { await readSecret(existing); return publicState(existing); } // An expired authorization that never reached an identity contains no // account choice or approval worth preserving. Replace that dead first-run // record automatically so an ordinary sign-in click cannot loop forever. // Once an identity exists, replacement remains the explicit Restart act. if (existing) { const expired = existing.status === "expired" ? existing : await expire(existing); if (accountSummary(expired.account)) return publicState(expired); await removeAuthorization(); } // A secret without its public state is an interrupted prior commit, not a // fresh machine. Only explicit Restart may delete that authorization-only // residue before a replacement is created. const canInspectResidue = typeof secretStore.inspect === "function"; const residue = canInspectResidue ? await secretStore.inspect() : await secretStore.read(); if (residue?.ok && (canInspectResidue ? residue.present === true : true)) { throw credentialError( "Relay found an unfinished one-time setup authorization. Restart setup to replace it safely.", "setup_restart_required", ); } if (residue && !residue.ok && residue.code && residue.code !== "credential_not_found") { throw credentialError( "Relay cannot inspect protected credential storage. Check its permissions, then try setup again.", residue.code || "credential_store_error", ); } const available = await secretStore.probe?.(); if (available && !available.ok) { throw credentialError( "Relay cannot access protected credential storage. Check its permissions, then try setup again.", available.code || "credential_store_error", ); } const { codeVerifier, codeChallenge } = pkcePair(); const created = await postJson(fetchImpl, base, "/v1/installation-authorizations", { deviceName: String(deviceName || "").trim() || "This computer", platform, codeChallenge, codeChallengeMethod: "S256", }); const authorizationId = String(created.authorizationId || ""); const clientSecret = String(created.clientSecret || ""); const activationUrl = String(created.activationUrl || ""); const expiresAt = validDate(created.expiresAt); const state = validateDurableState({ authorizationId, expiresAt, status: "pending_identity" }); validateSecret({ authorizationId, clientSecret, codeVerifier, activationUrl }, authorizationId, trustedWebOrigin); const stored = await secretStore.write({ authorizationId, clientSecret, codeVerifier, activationUrl }); if (!stored?.ok) { throw credentialError( "Relay could not protect the one-time setup authorization in protected storage.", stored?.code || "credential_store_error", ); } try { await durableStore.write(state); } catch (error) { let removed; try { removed = await secretStore.delete(); } catch (cleanupError) { throw credentialError( "Relay could not roll back the one-time setup authorization in protected storage.", cleanupError?.code || "credential_store_error", ); } if (!removed?.ok) { throw credentialError( "Relay could not roll back the one-time setup authorization in protected storage.", removed?.code || "credential_store_error", ); } throw error; } return publicState(state); } function begin() { if (!beginInFlight) { beginInFlight = serialize(beginInternal).finally(() => { beginInFlight = null; }); } return beginInFlight; } async function stateInternal() { const durable = await readDurable(); if (!durable) return { status: "idle" }; if (durable.status === "expired") return publicState(durable); if (Date.parse(durable.expiresAt) <= now()) return expire(durable); if (durable.status === "consumed") return publicState(durable); const secret = await readSecret(durable); let remote; try { remote = await postJson( fetchImpl, base, `/v1/installation-authorizations/${encodeURIComponent(durable.authorizationId)}/status`, { clientSecret: secret.clientSecret }, ); } catch (error) { if (error?.code === "authorization_expired") return expire(durable); throw error; } const status = String(remote.status || ""); const expiresAt = validDate(remote.expiresAt); if (!AUTHORIZATION_STATUSES.has(status) || !expiresAt) throw new InstallationRequestError("invalid_response", 200); const next = { ...durable, status, expiresAt, ...(accountSummary(remote.account) ? { account: accountSummary(remote.account) } : {}), }; await durableStore.write(next); if (status === "expired") return expire(next); return publicState(next); } function state() { return serialize(stateInternal); } async function googleInternal({ forceAccountSelection = false } = {}) { const { state: durable, secret } = await activeContext({ create: true }); const target = new URL(secret.activationUrl); if (forceAccountSelection) { const fragment = new URLSearchParams(target.hash.slice(1)); fragment.set("switchAccount", "1"); target.hash = fragment.toString(); } const opened = await openExternal(target.toString()); if (opened === false) throw new Error("Relay could not open the secure sign-in page."); return publicState(durable); } function google(options) { return serialize(() => googleInternal(options)); } async function emailStartInternal(email) { const normalized = String(email || "").trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) || normalized.length > 320) { throw new Error("Enter a valid email address."); } const { state: durable, secret } = await activeContext({ create: true }); const response = await postJson( fetchImpl, base, `/v1/installation-authorizations/${encodeURIComponent(durable.authorizationId)}/identity/email/start`, { clientSecret: secret.clientSecret, email: normalized }, ); if (response.status !== "code_sent" || !validDate(response.codeExpiresAt)) { throw new InstallationRequestError("invalid_response", 200); } return { status: "code_sent", codeExpiresAt: validDate(response.codeExpiresAt) }; } function emailStart(email) { return serialize(() => emailStartInternal(email)); } async function emailVerifyInternal(code) { const normalized = String(code || "").trim(); if (!/^\d{6}$/.test(normalized)) throw new Error("Enter the 6-digit code."); const { state: durable, secret } = await activeContext(); const response = await postJson( fetchImpl, base, `/v1/installation-authorizations/${encodeURIComponent(durable.authorizationId)}/identity/email/verify`, { clientSecret: secret.clientSecret, code: normalized }, ); const account = accountSummary(response.account); if (response.status !== "pending_approval" || !account) throw new InstallationRequestError("invalid_response", 200); const next = { ...durable, status: "pending_approval", account }; await durableStore.write(next); return publicState(next); } function emailVerify(code) { return serialize(() => emailVerifyInternal(code)); } async function approveAndConsume() { if (await isPaired()) { const pairedState = await readDurable(); if (pairedState?.status === "consumed") { await removeAuthorization(); return publicState(pairedState); } throw new Error("Relay is already connected on this computer."); } const { state: durable, secret } = await activeContext(); // `consumed` can be durable without a local token when the first consume // response was lost after the server committed it. The API's idempotent // consume recovery is the only safe repair; never re-approve first because // that endpoint correctly rejects a consumed authorization. if (durable.status !== "approved" && durable.status !== "consumed") { const approved = await postJson( fetchImpl, base, `/v1/installation-authorizations/${encodeURIComponent(durable.authorizationId)}/approve`, { clientSecret: secret.clientSecret }, ); if (approved.status !== "approved") throw new InstallationRequestError("invalid_response", 200); durable.status = "approved"; await durableStore.write(durable); } const registration = await postJson( fetchImpl, base, `/v1/installation-authorizations/${encodeURIComponent(durable.authorizationId)}/consume`, { clientSecret: secret.clientSecret, codeVerifier: secret.codeVerifier }, ); if ( !String(registration.deviceToken || "") || !String(registration.deviceId || "") || !String(registration.user?.id || "") || !String(registration.user?.email || "") ) { throw new InstallationRequestError("invalid_response", 200); } const connected = { ...durable, status: "consumed", account: { email: registration.user.email, displayName: registration.user.name || registration.user.email, }, }; // Commit the recoverable protocol state before the native account commit. // If this write fails the capability remains approved and consume can be // retried idempotently. If account persistence fails after it succeeds, // durable `consumed` + the PKCE secret is the recovery receipt. await durableStore.write(connected); await persistAccount(registration); const removed = await secretStore.delete(); if (removed?.ok) await durableStore.remove(); try { await onConnected(registration); } catch {} return publicState(connected); } function approve() { if (!consumeInFlight) { consumeInFlight = serialize(approveAndConsume).finally(() => { consumeInFlight = null; }); } return consumeInFlight; } async function resumeInternal() { if (await isPaired()) { const pairedState = await readDurable(); if (pairedState?.status === "consumed") { await removeAuthorization(); return publicState(pairedState); } throw new Error("Relay is already connected on this computer."); } let durable = await readDurable(); if (!durable) return { status: "idle" }; if (Date.parse(durable.expiresAt) <= now()) return expire(durable); if (durable.status === "approved" || durable.status === "consumed") { return approveAndConsume(); } // A local response may have been lost after the server committed approval. // Refresh first, then use only the idempotent consume recovery path. A // merely pending_approval authorization still requires the human button. const refreshed = await stateInternal(); if (refreshed.status === "expired") return refreshed; durable = await readDurable(); if (durable?.status === "approved" || durable?.status === "consumed") { return approveAndConsume(); } return publicState(durable); } function resume() { if (!resumeInFlight) { resumeInFlight = serialize(resumeInternal).finally(() => { resumeInFlight = null; }); } return resumeInFlight; } async function cancelInternal() { // Delete the authorization-only namespace even if its public state file is // absent (for example, after a state commit failed following secret write). await removeAuthorization(); return { status: "idle" }; } function cancel() { return serialize(cancelInternal); } async function restartInternal() { if (await isPaired()) throw new Error("Relay is already connected on this computer."); // This touches only the one-time installation-authorization namespace. It // never signs out, revokes a device, or removes account/E2EE/message state. await removeAuthorization(); return beginInternal(); } function restart() { return serialize(restartInternal); } return { state, begin, resume, restart, google, emailStart, emailVerify, approve, cancel }; }