Ctrl K
ComunidadGuías…

src/services/codex-images.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Opt-in, images-only Codex sign-in for the VPS and local ChatGPT sidecars. It uses the Codex CLI
// device flow, as Hermes Agent's "OpenAI (Codex auth)" provider does. OpenAI does not
// document this route for other apps, so it can stop working. Built-ins only: this file
// ships alone inside the sidecar image.
import * as fs from 'node:fs/promises';
import { constants } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { setTimeout as pause } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';

export const CODEX_IMAGES_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
export const CODEX_IMAGES_VERIFICATION_URL = 'https://auth.openai.com/codex/device';
export const CODEX_IMAGES_MODEL = 'gpt-image-2';

const ISSUER = 'https://auth.openai.com';
const USER_CODE_URL = `${ISSUER}/api/accounts/deviceauth/usercode`;
const DEVICE_TOKEN_URL = `${ISSUER}/api/accounts/deviceauth/token`;
const TOKEN_URL = `${ISSUER}/oauth/token`;
const REVOKE_URL = `${ISSUER}/oauth/revoke`;
const REDIRECT_URI = `${ISSUER}/deviceauth/callback`;
const AUTH_TIMEOUT_MS = 20_000;
const REVOKE_TIMEOUT_MS = 10_000;
const PENDING_MS = 15 * 60_000;
const REFRESH_MARGIN_MS = 5 * 60_000;
const MAX_ACCESS_LIFETIME_MS = 366 * 24 * 3600_000;
const MAX_FILE = 64 * 1024;
const LOCK_STALE_MS = 120_000;
const LOCK_RETRY_MS = 100;
// Longer than the slowest holder (poll + exchange, 2 x 20 s), shorter than the installer's 90 s exec.
const LOCK_ATTEMPTS = 450;
const REAUTHORIZE_CODES = new Set(['refresh_token_expired', 'refresh_token_reused', 'refresh_token_invalidated']);
const LEASE_CODES = new Set(['images_off', 'images_reauthorize', 'images_unavailable']);
const USER_CODE = /^[A-Z0-9]{2,16}(?:-[A-Z0-9]{2,16}){0,3}$/u;
const OPAQUE = /^[!-~]{1,4096}$/u;
const TOKEN = /^[!-~]{1,16384}$/u;
const ACCOUNT = /^[A-Za-z0-9_-]{6,128}$/u;
const PLAN = /^[A-Za-z0-9_.-]{1,32}$/u;
const RESIDENCY = /^[a-z-]{1,16}$/u;
const EMAIL = /^[^\p{C}\s@]+@[^\p{C}\s@]+$/u;
const LEFTOVER_RECORD = /^(session|pending)\.json\.[0-9a-f-]{36}\.tmp$/u;
const AUTH_CLAIM = 'https://api.openai.com/auth';
const uid = process.getuid?.();

class CodexImagesError extends Error {
  constructor(code, message) {
    super(message);
    this.code = code;
  }
}
// Messages are fixed text, so no token, code, verifier or upstream body can reach them.
const off = () => new CodexImagesError('images_off', 'Image generation is off. Sign in to Codex for images first.');
const reauthorize = () => new CodexImagesError('images_reauthorize', 'The Codex image sign-in expired. Sign in for images again.');
const unsafeStorage = () => new CodexImagesError('images_unavailable', 'Codex image sign-in storage is unsafe or invalid.');
const busy = () => new CodexImagesError('images_unavailable', 'Codex image sign-in is busy. Try again shortly.');
const refreshFailed = () => new CodexImagesError('images_unavailable', 'Codex image sign-in could not be refreshed. Try again later.');
const loginFailed = () => new CodexImagesError('images_login_failed', 'The Codex sign-in request failed. Try again.');
const startFailed = () => new CodexImagesError('images_login_failed',
  'The Codex sign-in request failed. Try again. If it keeps failing, check that device code sign-in is on in ChatGPT security settings.');
const deviceLoginDisabled = () => new CodexImagesError('images_device_login_disabled',
  'Codex device code sign-in is off for this account. Turn it on in ChatGPT security settings, or ask your workspace admin, then try again.');

const settings = (deps) => ({
  fetchImpl: deps?.fetchImpl ?? fetch,
  now: deps?.now ?? Date.now,
  fileSystem: deps?.fileSystem ?? fs,
  randomUUID: deps?.randomUUID ?? randomUUID,
  sleep: deps?.sleep ?? pause,
});
const iso = (ms) => new Date(ms).toISOString();
const isIso = (value) => typeof value === 'string' && value.length <= 32 && Number.isFinite(Date.parse(value));
const matches = (value, pattern) => typeof value === 'string' && pattern.test(value);
const isEmail = (value) => matches(value, EMAIL) && value.length <= 254;

function jwtClaims(token) {
  try {
    const parts = token.split('.');
    const claims = parts.length === 3 ? JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) : null;
    return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : {};
  } catch {
    return {};
  }
}
function authClaims(claims) {
  const value = claims[AUTH_CLAIM];
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}

// --- Store: ${storageRoot}/codex-images/{session.json,pending.json,lock} ---

// Only sign-in creates the directory; without it every other call is simply off.
async function storeDirectory(storageRoot, cfg, create = false) {
  if (typeof storageRoot !== 'string' || !isAbsolute(storageRoot) || storageRoot.includes('\0') ||
      resolve(storageRoot) !== storageRoot) throw unsafeStorage();
  const directory = join(storageRoot, 'codex-images');
  try {
    if (create) {
      try { await cfg.fileSystem.mkdir(directory, { mode: 0o700 }); }
      catch (error) { if (error?.code !== 'EEXIST') throw error; }
    }
    assertOwned(await cfg.fileSystem.lstat(directory), 'directory');
  } catch (error) {
    if (!create && error?.code === 'ENOENT') return null;
    throw unsafeStorage();
  }
  return directory;
}
function assertOwned(stat, kind) {
  if (stat.isSymbolicLink() || (kind === 'file' ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory()) ||
      (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) throw unsafeStorage();
  return stat;
}
async function readRecord(path, cfg) {
  try {
    let stat;
    try { stat = await cfg.fileSystem.lstat(path); }
    catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
    assertOwned(stat, 'file');
    if (stat.size === 0 || stat.size > MAX_FILE) throw unsafeStorage();
    const handle = await cfg.fileSystem.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
    try {
      const opened = await handle.stat();
      if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw unsafeStorage();
      const bytes = await handle.readFile();
      if (bytes.length > MAX_FILE) throw unsafeStorage();
      return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
    } finally {
      await handle.close();
    }
  } catch {
    throw unsafeStorage();
  }
}
async function writeRecord(path, value, cfg) {
  const bytes = Buffer.from(JSON.stringify(value));
  if (bytes.length > MAX_FILE) throw unsafeStorage();
  const temp = `${path}.${cfg.randomUUID()}.tmp`;
  try {
    const handle = await cfg.fileSystem.open(temp, 'wx', 0o600);
    try {
      await handle.writeFile(bytes);
      await handle.sync();
    } finally {
      await handle.close();
    }
    await cfg.fileSystem.rename(temp, path);
    const directory = await cfg.fileSystem.open(dirname(path), 'r');
    try { await directory.sync(); } finally { await directory.close(); }
  } catch {
    throw unsafeStorage();
  } finally {
    await cfg.fileSystem.rm(temp, { force: true }).catch(() => {});
  }
}
const removeRecord = (path, cfg) => cfg.fileSystem.rm(path, { force: true }).catch(() => { throw unsafeStorage(); });

function validSession(value) {
  return Boolean(value && typeof value === 'object' && value.schemaVersion === 1 &&
    value.clientId === CODEX_IMAGES_CLIENT_ID && matches(value.accountId, ACCOUNT) &&
    (value.email === undefined || isEmail(value.email)) && (value.planType === undefined || matches(value.planType, PLAN)) &&
    matches(value.accessToken, TOKEN) && matches(value.refreshToken, TOKEN) &&
    isIso(value.accessExpiresAt) && isIso(value.obtainedAt) && isIso(value.updatedAt) &&
    ['ok', 'in-flight', 'reauthorize'].includes(value.refreshState));
}
function validPending(value) {
  return Boolean(value && typeof value === 'object' && value.schemaVersion === 1 &&
    matches(value.deviceAuthId, OPAQUE) && matches(value.userCode, USER_CODE) &&
    Number.isSafeInteger(value.intervalSeconds) && value.intervalSeconds >= 5 && value.intervalSeconds <= 900 &&
    isIso(value.expiresAt) && isIso(value.createdAt) && isIso(value.nextPollAt));
}
async function readSession(directory, cfg) {
  const session = await readRecord(join(directory, 'session.json'), cfg);
  if (session !== null && !validSession(session)) throw unsafeStorage();
  return session;
}

// The lock holder keeps "in-flight" only while it holds the lock, so a holder that
// finds it left over knows a refresh crashed with an unknown rotation outcome.
async function lockedSession(directory, cfg) {
  const session = await readSession(directory, cfg);
  if (session?.refreshState !== 'in-flight') return session;
  const next = { ...session, refreshState: 'reauthorize', updatedAt: iso(cfg.now()) };
  await writeRecord(join(directory, 'session.json'), next, cfg);
  return next;
}
async function load(directory, cfg) {
  const session = await lockedSession(directory, cfg);
  const pendingPath = join(directory, 'pending.json');
  let pending = await readRecord(pendingPath, cfg);
  if (pending !== null && !validPending(pending)) throw unsafeStorage();
  let expired = false;
  if (pending && cfg.now() >= Date.parse(pending.expiresAt)) {
    await removeRecord(pendingPath, cfg);
    pending = null;
    expired = true;
  }
  return { session, pending, expired };
}
function statusOf({ session, pending }) {
  const account = session && {
    ...(session.email ? { email: session.email } : {}),
    ...(session.planType ? { planType: session.planType } : {}),
    accountIdSuffix: session.accountId.slice(-6),
  };
  if (session?.refreshState === 'ok') return { state: 'signed-in', account };
  if (pending) return { state: 'pending', pending: { userCode: pending.userCode, verificationUrl: CODEX_IMAGES_VERIFICATION_URL, expiresAt: pending.expiresAt } };
  if (session) return { state: 'reauthorize', account };
  return { state: 'off' };
}

async function lockAge(path, cfg) {
  let stat;
  try { stat = await cfg.fileSystem.lstat(path); }
  catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
  let createdAt = Number.NaN;
  if (stat.isFile() && stat.size <= 1024) {
    try { createdAt = Date.parse(JSON.parse(await cfg.fileSystem.readFile(path, 'utf8')).createdAt); }
    catch { /* A holder that crashed before writing falls back to the file time. */ }
  }
  if (!Number.isFinite(createdAt)) createdAt = stat.isFile() ? stat.mtimeMs : 0;
  return { ino: stat.ino, age: Math.abs(cfg.now() - createdAt) };
}
async function locked(directory, cfg, operation) {
  const path = join(directory, 'lock');
  let ino;
  for (let attempt = 0; ino === undefined; attempt++) {
    if (attempt > LOCK_ATTEMPTS) throw busy();
    let handle;
    try { handle = await cfg.fileSystem.open(path, 'wx', 0o600); }
    catch (error) {
      if (error?.code !== 'EEXIST') throw unsafeStorage();
      const held = await lockAge(path, cfg).catch(() => { throw unsafeStorage(); });
      if (held && held.age > LOCK_STALE_MS) {
        // ponytail: lstat-then-unlink can race a second stale taker by microseconds; stale locks only follow a crash.
        if ((await cfg.fileSystem.lstat(path).catch(() => null))?.ino === held.ino) await removeRecord(path, cfg);
      } else if (held) await cfg.sleep(LOCK_RETRY_MS);
      continue;
    }
    try {
      await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: iso(cfg.now()) }));
      ino = (await handle.stat()).ino;
    } catch {
      await cfg.fileSystem.rm(path, { force: true }).catch(() => {});
      throw unsafeStorage();
    } finally {
      await handle.close();
    }
  }
  try {
    return await operation();
  } finally {
    // A lock replaced as stale belongs to its new holder.
    if ((await cfg.fileSystem.lstat(path).catch(() => null))?.ino === ino)
      await cfg.fileSystem.rm(path, { force: true }).catch(() => {});
  }
}

// --- auth.openai.com ---

function post(cfg, url, body, { form = false, timeoutMs = AUTH_TIMEOUT_MS } = {}) {
  return cfg.fetchImpl(url, {
    method: 'POST',
    headers: { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json' },
    body: form ? new URLSearchParams(body) : JSON.stringify(body),
    redirect: 'error',
    signal: AbortSignal.timeout(timeoutMs),
  });
}
async function readJson(response) {
  const reader = response.body?.getReader();
  if (!reader) throw new Error('Empty response.');
  const chunks = [];
  let total = 0;
  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      total += value.byteLength;
      if (total > MAX_FILE) {
        await reader.cancel();
        throw new Error('Oversized response.');
      }
      chunks.push(value);
    }
  } finally {
    reader.releaseLock();
  }
  return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total)));
}
async function errorCode(response) {
  try {
    const body = await readJson(response);
    const code = typeof body?.error === 'string' ? body.error : body?.error?.code ?? body?.code;
    return typeof code === 'string' ? code : undefined;
  } catch {
    return undefined;
  }
}
// Builds a session from a token response. A refresh keeps the stored account and
// refresh token unless the response rotates them.
function sessionFrom(tokens, now, previous) {
  if (!tokens || typeof tokens !== 'object' || !matches(tokens.access_token, TOKEN) ||
      (tokens.refresh_token !== undefined || !previous) && !matches(tokens.refresh_token, TOKEN) ||
      tokens.id_token !== undefined && !matches(tokens.id_token, TOKEN)) throw new Error('Invalid token response.');
  const id = jwtClaims(tokens.id_token), access = jwtClaims(tokens.access_token);
  const accountId = previous?.accountId ??
    [authClaims(id).chatgpt_account_id, authClaims(access).chatgpt_account_id].find(value => matches(value, ACCOUNT));
  const email = isEmail(id.email) ? id.email : previous?.email;
  const planType = [authClaims(id).chatgpt_plan_type, authClaims(access).chatgpt_plan_type]
    .find(value => matches(value, PLAN)) ?? previous?.planType;
  const expiresAt = Number.isSafeInteger(access.exp) ? access.exp * 1000
    : Number.isSafeInteger(tokens.expires_in) ? now + tokens.expires_in * 1000 : Number.NaN;
  if (!accountId || !(expiresAt > now && expiresAt <= now + MAX_ACCESS_LIFETIME_MS)) throw new Error('Invalid token response.');
  return {
    schemaVersion: 1, clientId: CODEX_IMAGES_CLIENT_ID, accountId,
    ...(email ? { email } : {}), ...(planType ? { planType } : {}),
    accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? previous.refreshToken,
    accessExpiresAt: iso(expiresAt), obtainedAt: iso(now), refreshState: 'ok', updatedAt: iso(now),
  };
}
async function exchange(grant, cfg) {
  let response;
  try {
    response = await post(cfg, TOKEN_URL, {
      grant_type: 'authorization_code', client_id: CODEX_IMAGES_CLIENT_ID, code: grant.authorization_code,
      redirect_uri: REDIRECT_URI, code_verifier: grant.code_verifier,
    }, { form: true });
    if (!response.ok) throw loginFailed();
    return sessionFrom(await readJson(response), cfg.now());
  } catch {
    throw loginFailed();
  }
}
// Single-use refresh tokens: "in-flight" is on disk before the POST, and any outcome
// that may have rotated the token without our seeing the result ends in "reauthorize".
async function refresh(directory, session, cfg) {
  const path = join(directory, 'session.json');
  const settle = (refreshState) => writeRecord(path, { ...session, refreshState, updatedAt: iso(cfg.now()) }, cfg);
  await settle('in-flight');
  let response;
  try {
    response = await post(cfg, TOKEN_URL, {
      client_id: CODEX_IMAGES_CLIENT_ID, grant_type: 'refresh_token', refresh_token: session.refreshToken,
    });
  } catch {
    await settle('reauthorize');
    throw reauthorize();
  }
  if (!response.ok) {
    const code = await errorCode(response);
    if (response.status === 401 || REAUTHORIZE_CODES.has(code) || response.status === 400 && code === 'invalid_grant') {
      await settle('reauthorize');
      throw reauthorize();
    }
    await settle('ok');
    throw refreshFailed();
  }
  let next;
  try { next = sessionFrom(await readJson(response), cfg.now(), session); }
  catch {
    await settle('reauthorize');
    throw reauthorize();
  }
  await writeRecord(path, next, cfg);
  return next;
}
function leaseOf(session) {
  const claims = authClaims(jwtClaims(session.accessToken));
  const residency = [claims.chatgpt_data_residency, claims.chatgpt_compute_residency].find(value => matches(value, RESIDENCY));
  return {
    accessToken: session.accessToken, accountId: session.accountId,
    ...(residency ? { residency } : {}), fedramp: claims.chatgpt_account_is_fedramp === true,
  };
}

// --- Public operations ---

export async function codexImagesStatus({ storageRoot } = {}, deps) {
  const cfg = settings(deps);
  const directory = await storeDirectory(storageRoot, cfg);
  if (!directory) return { state: 'off' };
  return locked(directory, cfg, async () => statusOf(await load(directory, cfg)));
}

export async function startCodexImagesLogin({ storageRoot } = {}, deps) {
  const cfg = settings(deps);
  const directory = await storeDirectory(storageRoot, cfg, true);
  return locked(directory, cfg, async () => {
    const { session } = await load(directory, cfg);
    if (session?.refreshState === 'ok')
      throw new CodexImagesError('images_signed_in', 'Codex image sign-in is already on. Sign out of images first.');
    let response;
    try { response = await post(cfg, USER_CODE_URL, { client_id: CODEX_IMAGES_CLIENT_ID }); }
    catch { throw startFailed(); }
    // Codex treats a 404 here as device code sign-in being off for the account or workspace.
    if (response.status === 404) throw deviceLoginDisabled();
    let body;
    try {
      if (!response.ok) throw startFailed();
      body = await readJson(response);
    } catch {
      throw startFailed();
    }
    const userCode = body?.user_code ?? body?.usercode;
    if (!matches(body?.device_auth_id, OPAQUE) || !matches(userCode, USER_CODE)) throw startFailed();
    const intervalSeconds = Math.min(900, Math.max(5, Number.parseInt(String(body.interval ?? '5'), 10) || 5));
    const now = cfg.now();
    const pending = {
      schemaVersion: 1, deviceAuthId: body.device_auth_id, userCode, intervalSeconds,
      expiresAt: iso(now + PENDING_MS), createdAt: iso(now), nextPollAt: iso(now + intervalSeconds * 1000),
    };
    await writeRecord(join(directory, 'pending.json'), pending, cfg);
    return statusOf({ session, pending });
  });
}

export async function pollCodexImagesLogin({ storageRoot } = {}, deps) {
  const cfg = settings(deps);
  const directory = await storeDirectory(storageRoot, cfg);
  if (!directory) return { state: 'off' };
  return locked(directory, cfg, async () => {
    const current = await load(directory, cfg);
    if (current.expired) return { ...statusOf(current), outcome: 'expired' };
    const { pending } = current;
    if (!pending || cfg.now() < Date.parse(pending.nextPollAt)) return statusOf(current);
    const pendingPath = join(directory, 'pending.json');
    await writeRecord(pendingPath, { ...pending, nextPollAt: iso(cfg.now() + pending.intervalSeconds * 1000) }, cfg);
    let response;
    try { response = await post(cfg, DEVICE_TOKEN_URL, { device_auth_id: pending.deviceAuthId, user_code: pending.userCode }); }
    catch { throw loginFailed(); }
    if (response.status === 403 || response.status === 404) return statusOf(current);
    if (!response.ok) {
      if (response.status === 401 && await errorCode(response) === 'authorization_declined') {
        await removeRecord(pendingPath, cfg);
        return { ...statusOf({ session: current.session }), outcome: 'declined' };
      }
      throw loginFailed();
    }
    let grant;
    try { grant = await readJson(response); } catch { throw loginFailed(); }
    if (!matches(grant?.authorization_code, OPAQUE) || !matches(grant?.code_verifier, OPAQUE)) throw loginFailed();
    const session = await exchange(grant, cfg);
    await writeRecord(join(directory, 'session.json'), session, cfg);
    await removeRecord(pendingPath, cfg);
    return statusOf({ session });
  });
}

export async function cancelCodexImagesLogin({ storageRoot } = {}, deps) {
  const cfg = settings(deps);
  const directory = await storeDirectory(storageRoot, cfg);
  if (!directory) return { state: 'off' };
  return locked(directory, cfg, async () => {
    await removeRecord(join(directory, 'pending.json'), cfg);
    return statusOf(await load(directory, cfg));
  });
}

export async function signOutCodexImages({ storageRoot } = {}, deps) {
  const cfg = settings(deps);
  const directory = await storeDirectory(storageRoot, cfg);
  if (!directory) return { state: 'off', revocation: 'not-applicable' };
  return locked(directory, cfg, async () => {
    let session = null;
    let revocation = 'not-applicable';
    try { session = await readSession(directory, cfg); }
    catch { revocation = 'unconfirmed'; }
    if (session) {
      revocation = 'unconfirmed';
      try {
        const response = await post(cfg, REVOKE_URL, {
          token: session.refreshToken, token_type_hint: 'refresh_token', client_id: CODEX_IMAGES_CLIENT_ID,
        }, { timeoutMs: REVOKE_TIMEOUT_MS });
        // After an unknown rotation a newer refresh token may exist that this cannot revoke.
        if (response.ok && session.refreshState === 'ok') revocation = 'confirmed';
      } catch { /* Local sign-out still happens. */ }
    }
    await removeRecord(join(directory, 'session.json'), cfg);
    await removeRecord(join(directory, 'pending.json'), cfg);
    // A crash between write and rename can leave a token-bearing temp record behind.
    for (const name of await cfg.fileSystem.readdir(directory).catch(() => { throw unsafeStorage(); })) {
      if (!LEFTOVER_RECORD.test(name)) continue;
      const path = join(directory, name);
      if ((await cfg.fileSystem.lstat(path).catch(() => null))?.isFile()) await removeRecord(path, cfg);
    }
    return { state: 'off', revocation };
  });
}

export async function getCodexImagesLease({ storageRoot } = {}, deps) {
  const cfg = settings(deps);
  const fresh = (session) => session.refreshState === 'ok' && Date.parse(session.accessExpiresAt) - cfg.now() > REFRESH_MARGIN_MS;
  try {
    const directory = await storeDirectory(storageRoot, cfg);
    const seen = directory && await readSession(directory, cfg);
    if (!seen) throw off();
    if (seen.refreshState === 'reauthorize') throw reauthorize();
    if (fresh(seen)) return leaseOf(seen);
    // A peer may be refreshing ("in-flight"); re-read once its lock is released.
    return await locked(directory, cfg, async () => {
      const session = await lockedSession(directory, cfg);
      if (!session) throw off();
      if (session.refreshState !== 'ok') throw reauthorize();
      return leaseOf(fresh(session) ? session : await refresh(directory, session, cfg));
    });
  } catch (error) {
    if (error instanceof CodexImagesError && LEASE_CODES.has(error.code)) throw error;
    throw refreshFailed();
  }
}

const COMMANDS = {
  status: codexImagesStatus,
  'login-start': startCodexImagesLogin,
  'login-poll': pollCodexImagesLogin,
  'login-cancel': cancelCodexImagesLogin,
  'sign-out': signOutCodexImages,
};

export async function runCodexImagesCli({ command, storageRoot, output = process.stdout, deps } = {}) {
  let result;
  let exitCode = 0;
  try {
    if (!Object.hasOwn(COMMANDS, command)) throw new CodexImagesError('invalid_command', 'Unknown Codex images command.');
    result = await COMMANDS[command]({ storageRoot }, deps);
  } catch (error) {
    exitCode = 1;
    result = error instanceof CodexImagesError
      ? { error: error.code, message: error.message }
      : { error: 'images_failed', message: 'Codex image sign-in failed.' };
  }
  output.write(`${JSON.stringify(result)}\n`);
  return exitCode;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  process.exitCode = await runCodexImagesCli({ command: process.argv[2], storageRoot: process.env.N8N_OPENAI_OAUTH_HOME });
}