relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
856 lines (810 loc) • 33.3 kB
JavaScript
import { execFile as execFileCallback, spawn as spawnChild } from "node:child_process";
import {
createDecipheriv,
createPublicKey,
diffieHellman,
generateKeyPairSync,
hkdfSync,
randomBytes,
} from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { withCodexAppServer } from "./codex-app-server.js";
import { cliBinaryPath, installedCliVersions } from "./desktop-wake.js";
const execFileDefault = promisify(execFileCallback);
const PROVIDERS = Object.freeze({
claude: {
id: "claude",
label: "Claude Code",
candidates: (env = process.env) => [
env.CLAUDE_CLI_PATH,
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
path.join(os.homedir(), ".local", "bin", "claude"),
...installedCliVersions().reverse().map((version) => cliBinaryPath(version)),
"claude",
],
statusArgs: ["auth", "status", "--json"],
loginArgs: ["auth", "login", "--claudeai"],
},
codex: {
id: "codex",
label: "Codex",
candidates: (env = process.env) => [
env.CODEX_CLI_PATH,
"/Applications/ChatGPT.app/Contents/Resources/codex",
"/opt/homebrew/bin/codex",
"/usr/local/bin/codex",
path.join(os.homedir(), ".local", "bin", "codex"),
"codex",
],
statusArgs: ["login", "status"],
loginArgs: ["login"],
},
});
const activeLogins = new Map();
const lastAttempts = new Map();
const activeRemoteLogins = new Map();
const LOGIN_TIMEOUT_MS = 10 * 60 * 1_000;
const REMOTE_AUTH_OUTPUT_MAX_BYTES = 128 * 1024;
const REMOTE_AUTH_CONTEXT = "relay-provider-auth-code-v1";
const CLAUDE_MCP_BATCH_SIZE = "8";
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { linuxTerminalEnvironment, linuxTerminalInvocation } = require("../overlay/linux-terminal.cjs");
const EXPECT_RUNNER = path.join(MODULE_DIR, "provider-auth-runner.exp");
const TERMINAL_APP = "Terminal";
function configPath(root = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion")) {
return path.join(root, "provider-connections.json");
}
function readPrefs(file = configPath()) {
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function enabledFromPrefs(provider, prefs) {
return prefs?.providers?.[provider]?.enabled !== false;
}
function writePrefs(provider, enabled, file = configPath()) {
const prefs = readPrefs(file);
const next = {
...prefs,
version: 1,
providers: {
...(prefs.providers && typeof prefs.providers === "object" ? prefs.providers : {}),
[provider]: {
...(prefs?.providers?.[provider] && typeof prefs.providers[provider] === "object" ? prefs.providers[provider] : {}),
enabled: Boolean(enabled),
},
},
};
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, file);
try { fs.chmodSync(file, 0o600); } catch {}
return next;
}
function normalizedProviderCommand(candidate) {
const value = String(candidate || "").trim();
if (!value) return "";
if (!value.includes(path.sep) && !value.includes("/")) return value;
const absolute = path.resolve(value);
try {
fs.accessSync(absolute, fs.constants.X_OK);
return fs.statSync(absolute).isFile() ? absolute : "";
} catch {
return "";
}
}
function commandExists(candidate, { platform = process.platform } = {}) {
// Target-platform unit tests exercise macOS paths on other hosts. The actual
// target still verifies its files; a cross-platform probe can only preserve
// the injected command and let the spawn boundary report a real failure.
if (platform !== process.platform) return Boolean(String(candidate || "").trim());
return Boolean(normalizedProviderCommand(candidate));
}
export function resolveProviderCommand(provider, { command = "", env = process.env } = {}) {
const spec = PROVIDERS[String(provider || "")];
if (!spec) throw new Error("Unknown provider connection.");
if (command) return String(command).trim();
return spec.candidates(env).map(normalizedProviderCommand).find(Boolean) || "";
}
function safeMessage(value, max = 500) {
return String(value || "").replace(/[\r\n]+/g, " ").trim().slice(0, max);
}
function stripTerminalControl(value) {
return String(value || "")
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
}
function remoteAuthorizeUrl(provider, output) {
const matches = stripTerminalControl(output).match(/https:\/\/[^\s<>"']+/g) || [];
for (const candidate of matches) {
let parsed;
try { parsed = new URL(candidate); } catch { continue; }
if (parsed.protocol !== "https:" || parsed.username || parsed.password) continue;
if (provider === "claude" && parsed.hostname === "claude.com" && parsed.pathname === "/cai/oauth/authorize") {
return parsed.href;
}
if (provider === "codex" && parsed.hostname === "auth.openai.com" && parsed.pathname === "/codex/device"
&& !parsed.search && !parsed.hash) return parsed.href;
}
return "";
}
function codexDeviceCode(output) {
return stripTerminalControl(output).match(/\b[A-Z0-9]{4}-[A-Z0-9]{5}\b/)?.[0] || "";
}
function canonicalBase64Url(value, bytes) {
const text = String(value || "");
if (!/^[A-Za-z0-9_-]+$/.test(text)) throw new Error("The encrypted authorization response is invalid.");
const decoded = Buffer.from(text, "base64url");
if ((bytes && decoded.length !== bytes) || decoded.toString("base64url") !== text) {
throw new Error("The encrypted authorization response is invalid.");
}
return decoded;
}
function validateClaudeAuthorizationCode(value) {
const code = String(value || "");
if (!code || code !== code.trim() || code.length > 4096 || /[\u0000-\u001f\u007f]/.test(code)) {
throw new Error("The Claude authorization code is invalid.");
}
return code;
}
function decryptClaudeAuthorizationCode(authId, privateKey, envelope) {
if (!envelope || envelope.version !== 1) throw new Error("The encrypted authorization response is invalid.");
const peer = createPublicKey({
key: { kty: "OKP", crv: "X25519", x: canonicalBase64Url(envelope.ephemeralPublicKey, 32).toString("base64url") },
format: "jwk",
});
const shared = diffieHellman({ privateKey, publicKey: peer });
const key = Buffer.from(hkdfSync(
"sha256",
shared,
Buffer.from(authId, "utf8"),
Buffer.from(REMOTE_AUTH_CONTEXT, "utf8"),
32,
));
const nonce = canonicalBase64Url(envelope.nonce, 12);
const sealed = canonicalBase64Url(envelope.ciphertext);
if (sealed.length < 17 || sealed.length > 5_000) throw new Error("The encrypted authorization response is invalid.");
const ciphertext = sealed.subarray(0, -16);
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAAD(Buffer.from(authId, "utf8"));
decipher.setAuthTag(sealed.subarray(-16));
let plaintext;
try { plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); }
catch { throw new Error("The encrypted authorization response could not be opened on this computer."); }
return validateClaudeAuthorizationCode(plaintext);
}
export function providerAuthenticationFailure(value) {
return /authentication_failed|login expired|failed to authenticate|oauth access token.*revoked|not logged in|please run \/login/i
.test(String(value?.message || value || ""));
}
function safeIntegrationName(value) {
return String(value || "").trim().replace(/[^a-zA-Z0-9 ._+-]/g, "").slice(0, 80);
}
function localIntegrationNames(provider, homedir = os.homedir()) {
const names = new Set();
if (provider === "claude") {
try {
const config = JSON.parse(fs.readFileSync(process.env.CLAUDE_CODE_CONFIG || path.join(homedir, ".claude.json"), "utf8"));
for (const name of Object.keys(config?.mcpServers || {})) names.add(safeIntegrationName(name));
} catch {}
} else if (provider === "codex") {
try {
const source = fs.readFileSync(path.join(process.env.CODEX_HOME || path.join(homedir, ".codex"), "config.toml"), "utf8");
for (const match of source.matchAll(/^\s*\[mcp_servers\.([^\].]+)\]\s*$/gm)) names.add(safeIntegrationName(match[1]));
} catch {}
}
return [...names].filter(Boolean).sort((a, b) => a.localeCompare(b));
}
function integrationRecord(name, status = "Configured", source = "local") {
const safeName = safeIntegrationName(name);
if (!safeName) return null;
return { name: safeName, status: safeMessage(status, 80) || "Configured", source };
}
export function configuredProviderInventory({ homedir = os.homedir() } = {}) {
const providers = {};
for (const id of Object.keys(PROVIDERS)) {
providers[id] = {
integrations: {
mcpServers: localIntegrationNames(id, homedir).map((name) => integrationRecord(name)).filter(Boolean),
apps: [],
},
};
}
return { ok: true, providers };
}
function parseClaudeMcpList(stdout) {
const integrations = [];
for (const rawLine of String(stdout || "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || /^checking mcp server health/i.test(line) || /^no mcp servers/i.test(line)) continue;
const separator = line.lastIndexOf(" - ");
if (separator < 0) continue;
const left = line.slice(0, separator).trim();
const colon = left.indexOf(":");
if (colon < 0) continue;
let name = left.slice(0, colon).trim();
let source = "local";
if (name.startsWith("claude.ai ")) {
name = name.slice("claude.ai ".length).trim();
source = "account";
}
const rawStatus = line.slice(separator + 3).replace(/^[✔!✘✖]\s*/, "").trim();
const record = integrationRecord(name, rawStatus, source);
if (record) integrations.push(record);
}
return integrations.sort((a, b) => a.name.localeCompare(b.name));
}
async function installedCodexApps() {
return withCodexAppServer(async (client) => {
const snapshot = await client.request("app/installed", { forceRefresh: false });
return (Array.isArray(snapshot?.apps) ? snapshot.apps : [])
// These are Codex's own control surfaces, not user-connected apps.
.filter((app) => !String(app?.id || "").startsWith("connector_openai_"))
.map((app) => ({
name: safeIntegrationName(app?.runtimeName),
status: app?.callable ? "Connected" : app?.enabled ? "Unavailable" : "Disabled",
source: "account",
}))
.filter((app) => app.name)
.sort((a, b) => a.name.localeCompare(b.name));
});
}
async function providerIntegrations(provider, {
runtime,
execFile = execFileDefault,
timeoutMs = 8_000,
codexApps = installedCodexApps,
homedir = os.homedir(),
mcpBatchSize = CLAUDE_MCP_BATCH_SIZE,
} = {}) {
const configured = localIntegrationNames(provider, homedir)
.map((name) => integrationRecord(name))
.filter(Boolean);
if (!runtime) return { mcpServers: configured, apps: [] };
if (provider === "claude") {
try {
const result = await execFile(runtime, ["mcp", "list"], {
timeout: Math.max(timeoutMs, 15_000),
maxBuffer: 512 * 1024,
// Claude Code health-checks MCPs in batches. Its default of three makes
// Settings wait through multiple waves on connector-heavy accounts.
env: {
...process.env,
MCP_SERVER_CONNECTION_BATCH_SIZE: process.env.MCP_SERVER_CONNECTION_BATCH_SIZE || String(mcpBatchSize),
},
});
const discovered = parseClaudeMcpList(result?.stdout);
return { mcpServers: discovered.length ? discovered : configured, apps: [] };
} catch {
return { mcpServers: configured, apps: [] };
}
}
if (provider === "codex") {
try {
return { mcpServers: configured, apps: await codexApps() };
} catch {
return { mcpServers: configured, apps: [] };
}
}
return { mcpServers: configured, apps: [] };
}
function shellQuote(value) {
return `'${String(value ?? "").replace(/'/g, `'"'"'`)}'`;
}
function removeAttemptFiles(state) {
for (const file of [state?.scriptPath, state?.markerPath]) {
if (!file) continue;
try { fs.unlinkSync(file); } catch {}
}
}
function finishAttempt(id, state, message) {
if (!state || state.finished) return;
state.finished = true;
if (state.timer) clearTimeout(state.timer);
if (activeLogins.get(id) === state) activeLogins.delete(id);
removeAttemptFiles(state);
lastAttempts.set(id, { at: Date.now(), message });
}
function settleTerminalAttempt(id) {
const state = activeLogins.get(id);
if (!state?.markerPath || state.finished) return;
let result = "";
try { result = fs.readFileSync(state.markerPath, "utf8").trim(); } catch { return; }
const code = Number.parseInt(result, 10);
finishAttempt(id, state, code === 0
? "Authorization finished, but the subscription connection was not saved. Check the Terminal window, then try again."
: "Authorization did not complete. Check the Terminal window for the provider's error, then try again.");
}
function terminalAttemptPaths(id, prefsFile) {
const directory = path.join(path.dirname(prefsFile), "provider-auth");
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
try { fs.chmodSync(directory, 0o700); } catch {}
const nonce = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
return {
scriptPath: path.join(directory, `${id}-${nonce}.command`),
markerPath: path.join(directory, `${id}-${nonce}.result`),
};
}
function writeTerminalLoginScript(id, runtime, loginArgs, paths, { platform = process.platform } = {}) {
const label = PROVIDERS[id].label;
const script = `#!${platform === "linux" ? "/bin/sh" : "/bin/zsh"}
set -u
echo "Relay opened ${label}'s official subscription sign-in."
echo "Complete the provider and browser prompts in this window."
${[runtime, ...loginArgs].map(shellQuote).join(" ")}
result=$?
printf '%s\\n' "$result" > ${shellQuote(paths.markerPath)}
echo ""
if [ "$result" -eq 0 ]; then
echo "${label} finished. Return to Relay Settings to confirm the connection."
else
echo "${label} did not finish signing in. Resolve the error above, then reconnect from Relay Settings."
fi
echo "You may close this Terminal window."
exit "$result"
`;
fs.writeFileSync(paths.scriptPath, script, { mode: 0o700 });
try { fs.chmodSync(paths.scriptPath, 0o700); } catch {}
return paths;
}
function loginInvocation(runtime, loginArgs, {
platform = process.platform,
expectCommand = "/usr/bin/expect",
expectRunner = EXPECT_RUNNER,
} = {}) {
// Both provider CLIs own their OAuth callback and credential persistence.
// On macOS they must run inside a real pseudo-terminal. `script(1)` cannot
// provide that when Electron's stdio is a pipe/socket: it exits immediately
// with tcgetattr/ioctl ENOTTY and takes the OAuth callback listener with it.
// The system `expect` binary owns the PTY independently of Electron stdio.
if (platform === "darwin" && commandExists(expectCommand, { platform }) && commandExists(expectRunner, { platform })) {
return {
command: expectCommand,
args: ["-f", expectRunner, "--", runtime, ...loginArgs],
};
}
return { command: runtime, args: [...loginArgs] };
}
function attemptDetail(id, parsed) {
if (parsed.connected) {
lastAttempts.delete(id);
return parsed.detail;
}
const attempt = lastAttempts.get(id);
if (!attempt) return parsed.detail;
return attempt.message || "Authorization did not complete. Try again.";
}
function parseClaudeStatus(stdout) {
let payload = null;
const text = String(stdout || "").trim();
try { payload = JSON.parse(text); } catch {
// Some CLI builds print an update notice around --json output. Do not
// mislabel a valid first-party login as signed out because of that noise.
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start >= 0 && end > start) {
try { payload = JSON.parse(text.slice(start, end + 1)); } catch {}
}
}
const loggedIn = payload?.loggedIn === true;
const apiProvider = String(payload?.apiProvider || "");
const authMethod = String(payload?.authMethod || "");
const subscription = loggedIn && apiProvider === "firstParty" && authMethod !== "api_key";
const authState = subscription ? "subscription" : loggedIn ? "api_billing" : "signed_out";
return {
connected: subscription,
subscription,
authState,
method: subscription ? (authMethod || "claude.ai") : "",
detail: subscription
? "Claude subscription"
: loggedIn
? "Claude is signed in with API billing, not a subscription."
: "Sign in with your Claude subscription.",
};
}
function parseCodexStatus(stdout, stderr, exitCode = 0) {
const text = `${stdout || ""}\n${stderr || ""}`.trim();
const chatgpt = exitCode === 0 && /logged in using chatgpt/i.test(text);
const apiKey = /api key/i.test(text) && /logged in/i.test(text);
return {
connected: chatgpt,
subscription: chatgpt,
authState: chatgpt ? "subscription" : apiKey ? "api_billing" : "signed_out",
method: chatgpt ? "chatgpt" : "",
detail: chatgpt
? "ChatGPT subscription"
: apiKey
? "Codex is signed in with API billing, not ChatGPT."
: "Sign in with your ChatGPT subscription.",
};
}
export async function providerAuthStatus(provider, {
command = "",
execFile = execFileDefault,
prefsFile = configPath(),
timeoutMs = 8_000,
codexApps = installedCodexApps,
homedir = os.homedir(),
includeIntegrations = false,
} = {}) {
const id = String(provider || "");
const spec = PROVIDERS[id];
if (!spec) throw new Error("Unknown provider connection.");
settleTerminalAttempt(id);
const enabled = enabledFromPrefs(id, readPrefs(prefsFile));
const runtime = resolveProviderCommand(id, { command });
const base = {
id,
label: spec.label,
enabled,
installed: Boolean(runtime),
connected: false,
subscription: false,
authState: "signed_out",
method: "",
detail: runtime ? "Checking connection…" : `${spec.label} is not installed.`,
busy: activeLogins.has(id),
busyDetail: activeLogins.get(id)?.busyDetail || "",
requirement: id === "claude" ? "Claude subscription required" : "ChatGPT subscription required",
signInAction: id === "claude" ? "Sign in to Claude Code" : "Sign in to Codex",
integrations: { mcpServers: [], apps: [] },
};
if (!runtime) return base;
const integrationsPromise = includeIntegrations
? providerIntegrations(id, { runtime, execFile, timeoutMs, codexApps, homedir })
: Promise.resolve(base.integrations);
try {
const result = await execFile(runtime, spec.statusArgs, { timeout: timeoutMs, maxBuffer: 256 * 1024 });
const parsed = id === "claude"
? parseClaudeStatus(result?.stdout)
: parseCodexStatus(result?.stdout, result?.stderr, 0);
if (parsed.connected && activeLogins.has(id)) finishAttempt(id, activeLogins.get(id), "");
return { ...base, ...parsed, integrations: await integrationsPromise, detail: attemptDetail(id, parsed), busy: activeLogins.has(id) };
} catch (error) {
if (error?.code === "ENOENT") {
return { ...base, installed: false, detail: `${spec.label} is not installed.`, busy: false };
}
const stdout = error?.stdout || "";
const stderr = error?.stderr || "";
const parsed = id === "claude"
? parseClaudeStatus(stdout)
: parseCodexStatus(stdout, stderr, Number(error?.code) || 1);
return {
...base,
...parsed,
busy: activeLogins.has(id),
integrations: await integrationsPromise,
detail: attemptDetail(id, parsed) || safeMessage(stderr || error?.message),
};
}
}
export async function providerAuthStatuses(options = {}) {
const [claude, codex] = await Promise.all([
providerAuthStatus("claude", { ...options, includeIntegrations: false }),
providerAuthStatus("codex", { ...options, includeIntegrations: false }),
]);
return { ok: true, providers: { claude, codex } };
}
export async function providerInventoryStatuses(options = {}) {
const claudeRuntime = resolveProviderCommand("claude", options);
const codexRuntime = resolveProviderCommand("codex", options);
const [claude, codex] = await Promise.all([
providerIntegrations("claude", { ...options, runtime: claudeRuntime }),
providerIntegrations("codex", { ...options, runtime: codexRuntime }),
]);
return {
ok: true,
providers: {
claude: { integrations: claude },
codex: { integrations: codex },
},
};
}
export async function setProviderEnabled(provider, enabled, { prefsFile = configPath() } = {}) {
const id = String(provider || "");
if (!PROVIDERS[id]) throw new Error("Unknown provider connection.");
writePrefs(id, Boolean(enabled), prefsFile);
return { ok: true, provider: id, enabled: Boolean(enabled) };
}
export function providerSpawnEnvironment(env = process.env) {
// A provider login needs the graphical/user session, not the pill's complete
// deployment environment. Keep the same explicit allowlist used for normal
// Linux terminal opens so unknown URLs, cloud credentials, cookies, and Relay
// authority never cross into an external CLI by accident.
return linuxTerminalEnvironment(env);
}
/**
* Start the provider's official mobile-safe login flow without moving a
* credential into Relay. The returned challenge contains only an allowlisted
* provider URL/code. Claude's reply is encrypted to this attempt's ephemeral
* X25519 key before it crosses Relay's API.
*/
export function beginRemoteProviderAuth(provider, {
command = "",
execFile = execFileDefault,
spawn = spawnChild,
prefsFile = configPath(),
env = process.env,
now = Date.now,
} = {}) {
const id = String(provider || "");
const spec = PROVIDERS[id];
if (!spec) throw new Error("Unknown provider connection.");
const existing = activeRemoteLogins.get(id);
if (existing && !existing.terminal && existing.expiresAt > now()) return existing.publicAttempt;
const runtime = resolveProviderCommand(id, { command, env });
if (!runtime) throw new Error(`${spec.label} is not installed.`);
const authId = `pa_${randomBytes(16).toString("base64url")}`;
const ttlMs = id === "codex" ? 15 * 60 * 1_000 : 10 * 60 * 1_000;
const expiresAt = now() + ttlMs;
const replyKeys = id === "claude" ? generateKeyPairSync("x25519") : null;
let resolveChallenge;
let rejectChallenge;
let resolveCompletion;
let rejectCompletion;
const challenge = new Promise((resolve, reject) => { resolveChallenge = resolve; rejectChallenge = reject; });
const completion = new Promise((resolve, reject) => { resolveCompletion = resolve; rejectCompletion = reject; });
// Callers normally await both, but attach guards immediately so a spawn
// failure cannot become a process-level unhandled rejection.
challenge.catch(() => {});
completion.catch(() => {});
const args = id === "codex" ? ["login", "--device-auth"] : [...spec.loginArgs];
let child;
try {
child = spawn(runtime, args, {
windowsHide: true,
env: providerSpawnEnvironment(env),
stdio: [id === "claude" ? "pipe" : "ignore", "pipe", "pipe"],
});
} catch (error) {
throw new Error(`Could not start ${spec.label} authorization: ${safeMessage(error?.message || error)}`);
}
const state = {
id: authId,
provider: id,
child,
terminal: false,
challengeResolved: false,
submitted: false,
expiresAt,
output: "",
outputBytes: 0,
timer: null,
privateKey: replyKeys?.privateKey || null,
publicAttempt: null,
};
const finish = (error = null) => {
if (state.terminal) return;
state.terminal = true;
clearTimeout(state.timer);
if (activeRemoteLogins.get(id) === state) activeRemoteLogins.delete(id);
state.output = "";
if (error) {
const failure = error instanceof Error ? error : new Error(String(error));
if (!state.challengeResolved) rejectChallenge(failure);
rejectCompletion(failure);
} else {
if (!state.challengeResolved) {
state.challengeResolved = true;
resolveChallenge(null);
}
resolveCompletion({ ok: true, provider: id, authId });
}
};
const maybeResolveChallenge = () => {
if (state.challengeResolved) return;
const authorizeUrl = remoteAuthorizeUrl(id, state.output);
const userCode = id === "codex" ? codexDeviceCode(state.output) : "";
if (!authorizeUrl || (id === "codex" && !userCode)) return;
state.challengeResolved = true;
const view = {
kind: "provider_auth",
authId,
provider: id,
providerLabel: id === "claude" ? "Anthropic" : "OpenAI",
status: "waiting_for_user",
authorizeUrl,
...(userCode ? { userCode } : {}),
...(replyKeys ? { replyPublicKey: String(replyKeys.publicKey.export({ format: "jwk" }).x || "") } : {}),
expiresAt: new Date(expiresAt).toISOString(),
};
state.output = "";
resolveChallenge(view);
};
const collect = (chunk) => {
if (state.terminal || state.challengeResolved) return;
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
state.outputBytes += bytes.length;
if (state.outputBytes > REMOTE_AUTH_OUTPUT_MAX_BYTES) {
try { child.kill(); } catch {}
finish(new Error(`${spec.label} sign-in returned too much output.`));
return;
}
state.output += bytes.toString("utf8");
maybeResolveChallenge();
};
child.stdout?.on?.("data", collect);
child.stderr?.on?.("data", collect);
child.stdin?.on?.("error", () => {});
child.once?.("error", (error) => finish(new Error(`Could not start ${spec.label} authorization: ${safeMessage(error?.message || error)}`)));
child.once?.("close", async (code) => {
if (state.terminal) return;
try {
const status = await providerAuthStatus(id, { command: runtime, execFile, prefsFile });
if (status.connected) {
finish();
return;
}
finish(new Error(code === 0
? `${spec.label} sign-in completed without the required subscription.`
: `${spec.label} sign-in was cancelled or did not complete.`));
} catch (error) {
finish(new Error(code === 0
? `Could not verify ${spec.label} sign-in: ${safeMessage(error?.message || error)}`
: `${spec.label} sign-in was cancelled or did not complete.`));
}
});
state.timer = setTimeout(() => {
try { child.kill?.("SIGTERM"); } catch {}
finish(new Error(`${spec.label} sign-in expired before it completed.`));
}, ttlMs);
state.timer.unref?.();
const publicAttempt = {
id: authId,
provider: id,
challenge,
completion,
expiresAt,
submit(envelope) {
if (id !== "claude" || state.terminal || state.submitted || !state.challengeResolved || !state.privateKey) {
throw new Error("Claude sign-in is not waiting for an authorization code.");
}
const code = decryptClaudeAuthorizationCode(authId, state.privateKey, envelope);
state.submitted = true;
child.stdin.end(`${code}\n`, "utf8");
},
cancel() {
try { child.kill?.("SIGTERM"); } catch {}
finish(new Error(`${spec.label} sign-in was cancelled.`));
},
};
state.publicAttempt = publicAttempt;
activeRemoteLogins.set(id, state);
return publicAttempt;
}
export async function connectProvider(provider, {
command = "",
execFile = execFileDefault,
spawn = spawnChild,
prefsFile = configPath(),
platform = process.platform,
expectCommand = "/usr/bin/expect",
expectRunner = EXPECT_RUNNER,
openCommand = "/usr/bin/open",
terminalApp = TERMINAL_APP,
linuxTerminalInvocationImpl = linuxTerminalInvocation,
loginTimeoutMs = LOGIN_TIMEOUT_MS,
env = process.env,
} = {}) {
const id = String(provider || "");
const spec = PROVIDERS[id];
if (!spec) throw new Error("Unknown provider connection.");
const runtime = resolveProviderCommand(id, { command, env });
if (!runtime) throw new Error(`${spec.label} is not installed.`);
await setProviderEnabled(id, true, { prefsFile });
const current = await providerAuthStatus(id, { command: runtime, prefsFile, execFile });
if (current.connected) {
return { ok: true, provider: id, started: false, busy: false, alreadyConnected: true };
}
if (activeLogins.has(id)) return { ok: true, provider: id, started: false, busy: true };
lastAttempts.delete(id);
let paths = null;
let invocation = loginInvocation(runtime, spec.loginArgs, { platform, expectCommand, expectRunner });
if (platform === "darwin" && commandExists(openCommand, { platform })) {
paths = writeTerminalLoginScript(id, runtime, spec.loginArgs, terminalAttemptPaths(id, prefsFile), { platform });
invocation = { command: openCommand, args: ["-a", terminalApp, paths.scriptPath] };
} else if (platform === "linux") {
paths = writeTerminalLoginScript(id, runtime, spec.loginArgs, terminalAttemptPaths(id, prefsFile), { platform });
invocation = linuxTerminalInvocationImpl(paths.scriptPath, [], { env });
if (!invocation) {
removeAttemptFiles(paths);
throw new Error(`Could not start ${spec.label} authorization: no supported graphical terminal was found.`);
}
}
let child;
try {
child = spawn(invocation.command, invocation.args, {
env: providerSpawnEnvironment(env),
stdio: ["pipe", "pipe", "pipe"],
});
} catch (error) {
removeAttemptFiles(paths);
throw new Error(`Could not start ${spec.label} authorization: ${safeMessage(error?.message || error)}`);
}
const state = {
child,
startedAt: Date.now(),
timer: null,
finished: false,
scriptPath: paths?.scriptPath || "",
markerPath: paths?.markerPath || "",
busyDetail: paths ? `Finish ${spec.label} sign-in in Terminal.` : "Waiting for authorization…",
};
activeLogins.set(id, state);
// Never retain or return CLI output: it can contain short-lived OAuth URLs
// and codes. The authoritative success signal is a fresh provider status
// check after the official CLI exits.
child.stdout?.resume?.();
child.stderr?.resume?.();
child.stdin?.on?.("error", () => {});
child.once?.("error", (error) => {
finishAttempt(id, state, `Could not start authorization: ${safeMessage(error?.message || error)}`);
});
child.once?.("close", (code) => {
// `open` exits after handing the user-owned flow to Terminal. The result
// marker, not this launcher process, owns completion for that flow.
if (paths && code === 0) return;
finishAttempt(id, state, code === 0
? "Authorization finished, but the subscription connection was not saved. Try again."
: "Authorization was cancelled or did not complete. Try again.");
});
state.timer = setTimeout(() => {
try { child.kill?.("SIGTERM"); } catch {}
finishAttempt(id, state, "Authorization timed out. Check the Terminal window, then try again.");
}, Math.max(1_000, Number(loginTimeoutMs) || LOGIN_TIMEOUT_MS));
state.timer.unref?.();
return { ok: true, provider: id, started: true, busy: true, interaction: paths ? "terminal" : "inline" };
}
export async function assertProviderReady(provider, options = {}) {
const status = await providerAuthStatus(provider, options);
if (!status.enabled) {
throw new Error(`${status.label} is disabled in Relay. Enable it in Settings before starting this request.`);
}
if (!status.installed) {
throw new Error(`${status.label} is not installed. Install it, then open Relay Settings → Agent connections.`);
}
if (!status.connected) {
const subscription = status.id === "claude" ? "Claude subscription" : "ChatGPT subscription";
const action = status.id === "claude" ? "Sign in to Claude Code" : "Sign in to Codex";
if (status.authState === "api_billing") {
throw new Error(`${status.label} is signed in with API billing, but Tasks use your ${subscription}. Open Relay Settings → Agent connections and choose “${action}”.`);
}
throw new Error(`${status.label} needs your ${subscription} before this Task can start. Open Relay Settings → Agent connections and choose “${action}”.`);
}
return status;
}
export const _test = {
parseClaudeStatus,
parseCodexStatus,
readPrefs,
writePrefs,
configPath,
activeLogins,
lastAttempts,
loginInvocation,
shellQuote,
terminalAttemptPaths,
writeTerminalLoginScript,
settleTerminalAttempt,
localIntegrationNames,
parseClaudeMcpList,
providerIntegrations,
stripTerminalControl,
remoteAuthorizeUrl,
codexDeviceCode,
decryptClaudeAuthorizationCode,
activeRemoteLogins,
};