Ctrl K
ComunidadGuías…

scripts/cloud-private-home.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * Private operator files for Claude Code cloud sessions.
 *
 * The guarded deploy and the lane tooling read owner-only files that exist
 * only on the maintainer's machine: the lane secrets file, the lane credential
 * files, and the seed manifest. A cloud session starts from a fresh VM whose
 * only configuration is environment variables, so the SessionStart hook
 * (scripts/cloud-session-start.sh) runs this script to write those files from
 * base64 variables, at the paths the readers already use:
 *
 *   CHICKPEA_QA_SECRETS_ENV_B64    ~/.chickpea/qa-secrets.env
 *   CHICKPEA_LANE_CREDENTIALS_B64  ~/.chickpea/lane-credentials/<lane>-live.json
 *                                  and <lane>-seed.json, from one JSON object
 *                                  keyed by file name
 *   CHICKPEA_QA_SEED_JSON_B64      ~/.chickpea/qa-seed.json
 *
 * It writes only when CLAUDE_CODE_REMOTE=true. An absent or empty variable is
 * skipped without a word. A present but malformed one fails the session
 * start, naming the variable and never its value. Each file is checked by the
 * reader that will consume it before it lands, written 0600 under a 0700
 * directory, and rechecked with assertPrivatePath afterwards, so the readers
 * accept it exactly as they accept the maintainer's own files. Output names
 * files and counts only.
 *
 *   node scripts/cloud-private-home.mjs           # cloud session: write the files
 *   node scripts/cloud-private-home.mjs encode    # maintainer: print the variables
 *
 * `encode` reads the maintainer's own files and prints NAME=value lines for
 * the cloud environment's variables. Those lines carry the secrets: paste them
 * into the environment and keep them out of shells, logs, and Git. Line breaks
 * inside a value are ignored, so a wrapped base64 value is fine.
 */
import { randomBytes } from 'node:crypto';
import {
  closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync,
  writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { defaultSeedManifest, parseManifest } from './lane-seed.mjs';
import {
  defaultLaneSecretsFile,
  LANE_CREDENTIALS_DIR_ENV,
  laneCredentialsDirectory,
  parseLaneSecrets,
  readLaneSecretEntries,
  readLaneSeedToken,
} from './lib/lane-secrets.mjs';
import { QA_LANES } from './lib/qa-lanes.mjs';
import { assertPrivatePath } from './lib/upgrade-receipt.mjs';

export const REMOTE_SESSION_ENV = 'CLAUDE_CODE_REMOTE';
export const QA_SECRETS_ENV_VARIABLE = 'CHICKPEA_QA_SECRETS_ENV_B64';
export const LANE_CREDENTIALS_VARIABLE = 'CHICKPEA_LANE_CREDENTIALS_B64';
export const QA_SEED_JSON_VARIABLE = 'CHICKPEA_QA_SEED_JSON_B64';
export const LANE_CREDENTIAL_FILE = new RegExp(`^(${QA_LANES.join('|')})-(live|seed)\\.json$`, 'u');

const PREFIX = 'cloud-private-home';
const MAX_ENCODED_LENGTH = 1024 * 1024;
const SECRETS_LABEL = 'The lane secrets file';
const CREDENTIALS_LABEL = 'The lane credentials directory';
const MANIFEST_LABEL = 'The seed manifest';
const USAGE = [
  'Usage: node scripts/cloud-private-home.mjs [encode]',
  '  (no argument)  In a Claude Code cloud session (CLAUDE_CODE_REMOTE=true), write the',
  `                 operator's private ~/.chickpea files from ${QA_SECRETS_ENV_VARIABLE},`,
  `                 ${LANE_CREDENTIALS_VARIABLE}, and ${QA_SEED_JSON_VARIABLE}.`,
  '  encode         Print those variables from the files on this machine (carries secrets).',
].join('\n');

const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);

/** Decode one base64 variable to text. Undefined when absent or blank; throws naming the variable, never the value. */
export function decodeVariable(env, name) {
  const raw = env[name];
  if (raw === undefined) return undefined;
  const compact = String(raw).replace(/\s+/gu, '');
  if (compact === '') return undefined;
  if (compact.length > MAX_ENCODED_LENGTH) throw new Error(`${name} exceeds its size limit.`);
  if (!/^[A-Za-z0-9+/]+={0,2}$/u.test(compact) || compact.length % 4 !== 0) throw new Error(`${name} is not base64.`);
  const bytes = Buffer.from(compact, 'base64');
  if (bytes.toString('base64') !== compact) throw new Error(`${name} is not base64.`);
  let text;
  try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { throw new Error(`${name} does not decode to UTF-8 text.`); }
  if (text.includes('\0')) throw new Error(`${name} decodes to binary, not text.`);
  return text;
}

function withVariable(name, work) {
  try {
    return work();
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    throw new Error(message.startsWith(name) ? message : `${name}: ${message}`);
  }
}

/**
 * Stage text as a 0600 temporary file beside its destination. `validate` sees
 * the temporary path, so a file its reader rejects never lands; `commit`
 * renames it into place and rechecks it. Refuses a symlink or a shared
 * directory rather than following it.
 */
function stagePrivateText(file, text, { label, validate, report }) {
  const directory = path.dirname(file);
  mkdirSync(directory, { recursive: true, mode: 0o700 });
  assertPrivatePath(directory, { directory: true, label });
  if (existsSync(file)) assertPrivatePath(file, { label });
  const temporary = `${file}.${randomBytes(8).toString('hex')}.tmp`;
  const discard = () => { if (existsSync(temporary)) unlinkSync(temporary); };
  try {
    const fd = openSync(temporary, 'wx', 0o600);
    try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
    validate?.(temporary);
  } catch (error) {
    discard();
    throw error;
  }
  return {
    discard,
    commit: () => {
      renameSync(temporary, file);
      assertPrivatePath(file, { label });
      return `wrote ${file}${report ? ` (${report})` : ''}`;
    },
  };
}

/** The bundle behind CHICKPEA_LANE_CREDENTIALS_B64: a JSON object keyed by lane credential file name. */
export function parseLaneCredentialBundle(text) {
  let bundle;
  try { bundle = JSON.parse(text); } catch { throw new Error(`${LANE_CREDENTIALS_VARIABLE} is not readable JSON.`); }
  if (!isPlainObject(bundle)) throw new Error(`${LANE_CREDENTIALS_VARIABLE} must be a JSON object keyed by file name.`);
  Object.keys(bundle).forEach((name, index) => {
    if (!LANE_CREDENTIAL_FILE.test(name)) {
      throw new Error(`${LANE_CREDENTIALS_VARIABLE}: entry ${index + 1} is not a lane credential file name (<lane>-live.json or <lane>-seed.json for ${QA_LANES.join(', ')}).`);
    }
    if (!isPlainObject(bundle[name])) throw new Error(`${LANE_CREDENTIALS_VARIABLE}: ${name} must be a JSON object.`);
  });
  return bundle;
}

/** The reader's view of one credential file in `directory`; throws without echoing values. */
function validateLaneCredential(name, value, directory) {
  const [, lane, kind] = LANE_CREDENTIAL_FILE.exec(name);
  if (kind === 'seed') {
    try {
      readLaneSeedToken(lane, { env: { [LANE_CREDENTIALS_DIR_ENV]: directory } });
    } catch {
      throw new Error(`${LANE_CREDENTIALS_VARIABLE}: ${name} is not a lane seed token file (target "${lane}" and a seedToken).`);
    }
    return;
  }
  // resolveLaneAuthorityCredentials reads `origin` and `authorityReadToken`.
  const origin = typeof value.origin === 'string' ? value.origin.trim() : '';
  const token = typeof value.authorityReadToken === 'string' ? value.authorityReadToken.trim() : '';
  let url;
  try { url = new URL(origin); } catch { url = undefined; }
  if (!url || !/^https?:$/u.test(url.protocol) || !token) {
    throw new Error(`${LANE_CREDENTIALS_VARIABLE}: ${name} needs an http(s) origin and an authorityReadToken.`);
  }
}

function stageSecretsEnv(env, text) {
  const entries = parseLaneSecrets(text);
  return stagePrivateText(defaultLaneSecretsFile(env), text, {
    label: SECRETS_LABEL,
    validate: (temporary) => readLaneSecretEntries({ env: {}, file: temporary }),
    report: `${entries.size} names`,
  });
}

/**
 * Stage every credential file in a private sibling directory, where the real
 * readers validate them at their expected names; `commit` moves them in.
 */
function stageLaneCredentials(env, bundle) {
  const names = Object.keys(bundle).sort();
  const directory = laneCredentialsDirectory(env);
  mkdirSync(directory, { recursive: true, mode: 0o700 });
  assertPrivatePath(directory, { directory: true, label: CREDENTIALS_LABEL });
  const staging = path.join(path.dirname(directory), `.${path.basename(directory)}.${randomBytes(8).toString('hex')}.tmp`);
  mkdirSync(staging, { mode: 0o700 });
  const discard = () => rmSync(staging, { recursive: true, force: true });
  try {
    for (const name of names) {
      writeFileSync(path.join(staging, name), `${JSON.stringify(bundle[name], null, 2)}\n`, { mode: 0o600, flag: 'wx' });
    }
    for (const name of names) validateLaneCredential(name, bundle[name], staging);
    for (const name of names) {
      if (existsSync(path.join(directory, name))) assertPrivatePath(path.join(directory, name), { label: CREDENTIALS_LABEL });
    }
  } catch (error) {
    discard();
    throw error;
  }
  return {
    discard,
    commit: () => {
      try {
        for (const name of names) {
          const file = path.join(directory, name);
          renameSync(path.join(staging, name), file);
          assertPrivatePath(file, { label: CREDENTIALS_LABEL });
        }
      } finally {
        discard();
      }
      return `wrote ${names.join(', ')} in ${directory}`;
    },
  };
}

function stageSeedManifest(text) {
  const connections = parseManifest(text);
  return stagePrivateText(defaultSeedManifest(), text, { label: MANIFEST_LABEL, report: `${connections.length} connections` });
}

/**
 * Write the private files a cloud session's environment carries. Every
 * variable is decoded, parsed, and staged past its reader before any file
 * lands, so one malformed variable refuses the whole step and leaves nothing
 * behind. Returns the lines to report.
 */
export function materializePrivateHome(env = process.env) {
  const written = [];
  if (env[REMOTE_SESSION_ENV] !== 'true') return { remote: false, written };
  const secretsText = withVariable(QA_SECRETS_ENV_VARIABLE, () => {
    const text = decodeVariable(env, QA_SECRETS_ENV_VARIABLE);
    if (text !== undefined) parseLaneSecrets(text);
    return text;
  });
  const bundle = withVariable(LANE_CREDENTIALS_VARIABLE, () => {
    const text = decodeVariable(env, LANE_CREDENTIALS_VARIABLE);
    return text === undefined ? undefined : parseLaneCredentialBundle(text);
  });
  const manifestText = withVariable(QA_SEED_JSON_VARIABLE, () => {
    const text = decodeVariable(env, QA_SEED_JSON_VARIABLE);
    if (text !== undefined) parseManifest(text);
    return text;
  });
  const staged = [];
  try {
    if (secretsText !== undefined) staged.push(withVariable(QA_SECRETS_ENV_VARIABLE, () => stageSecretsEnv(env, secretsText)));
    if (bundle !== undefined && Object.keys(bundle).length > 0) {
      staged.push(withVariable(LANE_CREDENTIALS_VARIABLE, () => stageLaneCredentials(env, bundle)));
    }
    if (manifestText !== undefined) staged.push(withVariable(QA_SEED_JSON_VARIABLE, () => stageSeedManifest(manifestText)));
    for (const stage of staged) written.push(stage.commit());
  } finally {
    for (const stage of staged) stage.discard();
  }
  return { remote: true, written };
}

/** NAME=value lines for the cloud environment, from this machine's private files. */
export function encodePrivateHome(env = process.env) {
  const lines = [];
  const encode = (text) => Buffer.from(text, 'utf8').toString('base64');
  const secretsFile = defaultLaneSecretsFile(env);
  if (existsSync(secretsFile)) {
    readLaneSecretEntries({ env, file: secretsFile });
    lines.push(`${QA_SECRETS_ENV_VARIABLE}=${encode(readFileSync(secretsFile, 'utf8'))}`);
  }
  const directory = laneCredentialsDirectory(env);
  if (existsSync(directory)) {
    assertPrivatePath(directory, { directory: true, label: CREDENTIALS_LABEL });
    const bundle = {};
    for (const name of readdirSync(directory).filter((entry) => LANE_CREDENTIAL_FILE.test(entry)).sort()) {
      const file = path.join(directory, name);
      assertPrivatePath(file, { label: CREDENTIALS_LABEL });
      try { bundle[name] = JSON.parse(readFileSync(file, 'utf8')); } catch { throw new Error(`${file} is not readable JSON.`); }
    }
    parseLaneCredentialBundle(JSON.stringify(bundle));
    for (const name of Object.keys(bundle)) validateLaneCredential(name, bundle[name], directory);
    if (Object.keys(bundle).length > 0) lines.push(`${LANE_CREDENTIALS_VARIABLE}=${encode(JSON.stringify(bundle))}`);
  }
  const manifest = defaultSeedManifest();
  if (existsSync(manifest)) {
    const text = readFileSync(manifest, 'utf8');
    parseManifest(text);
    lines.push(`${QA_SEED_JSON_VARIABLE}=${encode(text)}`);
  }
  return lines;
}

export function main(argv) {
  if (argv.includes('--help') || argv.includes('-h')) { console.log(USAGE); return 0; }
  const [command, ...rest] = argv;
  if (rest.length > 0 || (command !== undefined && command !== 'encode')) { console.error(USAGE); return 2; }
  try {
    if (command === 'encode') {
      const lines = encodePrivateHome();
      if (lines.length === 0) {
        console.error(`${PREFIX}: no private files under ${path.join(homedir(), '.chickpea')} to encode.`);
        return 1;
      }
      console.error(`${PREFIX}: the lines below carry secrets; paste them into the cloud environment's variables and discard them.`);
      console.log(lines.join('\n'));
      return 0;
    }
    for (const line of materializePrivateHome().written) console.log(`${PREFIX}: ${line}`);
    return 0;
  } catch (error) {
    console.error(`${PREFIX}: ${error instanceof Error ? error.message : String(error)}`);
    return 1;
  }
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  process.exitCode = main(process.argv.slice(2));
}