Ctrl K
ComunidadGuías…

src/services/vps-claude.js

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

Archivo original ↗Preguntar a KiBot
// Owner-only Claude companion on the managed VPS: the bundle from `relmio claude companion prepare`,
// deployed by the wizard to /docker/n8n-openai-oauth/claude after review and final confirmation.
// The owner signs in from their own terminal with login.sh; Relmio never reads or moves the Claude login,
// which stays in the companion's Docker volume. Only three auth status fields ever leave the VPS.
import { createHash, randomBytes } from "node:crypto";
import { validateDockerName, validateHostname, validatePort, validateUsername } from "../domain/validation.js";
import { validateDockerObjectId } from "../domain/local-n8n-sidecar.js";
import { validateInstallId } from "../domain/local-endpoints.js";
import { INSTALL_ROOT, PRECHECK_COMMAND, SHARED_ROOT_COMMAND, SHARED_ROOT_TIGHTEN_COMMAND, SHARED_ROOT_MARKER_PATH, SHARED_ROOT_MARKER_CONTENT } from "../domain/safety.js";
import { VPS_OPERATION_LOCKS } from "../domain/vps-build-state.js";
import {
  CLAUDE_COMPANION, buildClaudeCompanionBundle, claudeArchFromMachine, claudeCompanionPin,
  parseClaudeAuthStatus, renewClaudeLaunchConfig, reviewClaudeCompanion,
} from "../claude/companion.js";
import { CANDIDATE_VERSION, MODEL_RULES, validModels } from "../claude/launcher.js";
import { withVpsOperationLock } from "./vps-operation-lock.js";

export const VPS_CLAUDE_ROOT = `${INSTALL_ROOT}/claude`;
const ROOT = VPS_CLAUDE_ROOT;
const MARKER_NAME = ".managed-by-relmio.json";
const MARKER = `${ROOT}/${MARKER_NAME}`;
const LOCK = VPS_OPERATION_LOCKS.claude;
// One companion per Docker host: the template fixes the compose project, container name and network alias.
const NAME = CLAUDE_COMPANION.project;
const VOLUME = `${NAME}_claude-account`;
const IMAGE = `${NAME}:${CANDIDATE_VERSION}`;
const COMPOSE = `docker compose --project-name ${NAME} --file ${ROOT}/compose.yml`;
const ACTIONS = Object.freeze(["install", "replace", "renew", "sign-out", "remove"]);
const ALLOWED_STATES = Object.freeze({
  install: ["absent"], replace: ["manual", "installed"], renew: ["installed"], "sign-out": ["installed"], remove: ["installed"],
});
const REVIEW_FIELDS = Object.freeze(["officialBinaryReviewed", "effectivePolicyReviewed", "nativeAuthAndBillingReviewed", "policyChangesControlled"]);
const MAX_FILE_BYTES = 16 * 1024;
const DIRECTORIES = Object.freeze(["app", "app/src", "app/src/gateway", "app/src/claude"]);
// Every bundle file except launch.json is hashed in the marker; renewals rewrite only launch.json.
const FIXED_FILES = Object.freeze({
  Dockerfile: "644", "compose.yml": "644", "login.sh": "644", "renew.sh": "644",
  "app/src/gateway/claude-chat.js": "644", "app/src/claude/launcher.js": "644", "app/package.json": "644", ".env": "600",
});
const ENTRY_SPEC = new Map([
  [MARKER_NAME, { type: "f", owner: "0:0", mode: "600" }],
  ...Object.entries(FIXED_FILES).map(([path, mode]) => [path, { type: "f", owner: "0:0", mode }]),
  ["launch.json", { type: "f", owner: "10001:10001", mode: "600" }],
  ...DIRECTORIES.map((path) => [path, { type: "d", owner: "0:0", mode: "755" }]),
]);
// A bundle from `relmio claude companion prepare` may also hold its one-time key file; replacing it deletes that copy.
const MANUAL_TYPES = new Map([
  ...[...ENTRY_SPEC].filter(([path]) => path !== MARKER_NAME).map(([path, { type }]) => [path, type]),
  ["n8n-api-key.txt", "f"],
]);
const KNOWN_FILES = [...MANUAL_TYPES].filter(([, type]) => type === "f").map(([path]) => path);
const hash = (value) => createHash("sha256").update(value).digest("hex");
const fail = () => new Error("The VPS Claude ownership or safety check failed. Nothing outside its managed companion may be changed.");
const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
const parse = (text) => { try { return JSON.parse(text); } catch { throw fail(); } };
const parentGuard = `[ -d /docker ] && [ ! -L /docker ] && [ ! -L ${INSTALL_ROOT} ]`;
const rootGuard = `${parentGuard} && [ ! -L ${ROOT} ] && [ -d ${ROOT} ]`;
const LISTING = `if [ ! -e ${ROOT} ] && [ ! -L ${ROOT} ]; then printf 'absent\\n'; elif ${rootGuard}; then stat -c 'root %u:%g %a' ${ROOT} && find ${ROOT} -mindepth 1 -printf '%P %y %U:%G %m %n %s\\n'; else printf 'unsafe\\n'; fi`;
// Deletes only the fixed bundle names, deepest folders last; an unexpected entry makes rmdir fail.
const CLEAR_BUNDLE = `rm -f -- ${[MARKER_NAME, ...KNOWN_FILES].join(" ")} && for d in ${[...DIRECTORIES].reverse().join(" ")}; do if [ -d "$d" ]; then rmdir -- "$d" || exit 1; fi; done`;
const REMOVE_ROOT = `${rootGuard} && cd ${ROOT} && ${CLEAR_BUNDLE} && cd / && rmdir ${ROOT}`;
// GNU coreutils on the Linux VPS: exclusive create, 10001-owned 0600, sync, then one atomic rename.
export function createVpsClaudeLaunchWriteCommand({ guardRoot = "/docker", installRoot = INSTALL_ROOT } = {}) {
  const safePath = /^\/[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/u;
  if (typeof guardRoot !== "string" || typeof installRoot !== "string" || !safePath.test(guardRoot) ||
    !safePath.test(installRoot) || !installRoot.startsWith(`${guardRoot}/`)) throw new TypeError("The Claude write roots are invalid.");
  const root = `${installRoot}/claude`;
  const guard = `[ -d ${guardRoot} ] && [ ! -L ${guardRoot} ] && [ ! -L ${installRoot} ] && [ ! -L ${root} ] && [ -d ${root} ]`;
  return `${guard} && cd ${root} && [ ! -e launch.json.next ] && [ ! -L launch.json.next ] && umask 077 && if (set -C; cat > launch.json.next) && chown -h 10001:10001 launch.json.next && [ -f launch.json.next ] && [ ! -L launch.json.next ] && [ "$(stat -c %u:%g:%a:%h launch.json.next)" = 10001:10001:600:1 ] && sync launch.json.next && mv -fT launch.json.next launch.json; then exit 0; else rm -f launch.json.next; exit 1; fi`;
}
// The key arrives on stdin, never in argv, and only the configured model IDs come back.
export const VPS_CLAUDE_MODELS_PROBE = `let k="";process.stdin.setEncoding("utf8");process.stdin.on("data",c=>k+=c);process.stdin.on("end",()=>{const r=require("node:http").get({host:"127.0.0.1",port:${CLAUDE_COMPANION.port},path:"/v1/models",headers:{Host:"${CLAUDE_COMPANION.alias}:${CLAUDE_COMPANION.port}",Authorization:"Bearer "+k},timeout:20000},s=>{let b="";s.setEncoding("utf8");s.on("data",c=>b+=c);s.on("end",()=>{let m=[];try{if(s.statusCode===200)m=JSON.parse(b).data.map(x=>x.id)}catch{}console.log(JSON.stringify({status:s.statusCode,models:m}))})});r.on("timeout",()=>r.destroy());r.on("error",()=>console.log(JSON.stringify({status:502,models:[]})))});`;

async function run(remote, command, input) {
  const result = await remote.exec(command, input === undefined ? undefined : { input });
  if (result.code !== 0) throw fail();
  return result.stdout.trim();
}

// Sets an existing shared folder to mode 0700. Only renew, signOut and remove call it. Each runs
// from applyVpsClaude after it checked the owner's final confirmation and re-attested the
// companion under the lock. Status reads and the sign-in and model checks never call it.
const tightenSharedFolder = (remote) => run(remote, SHARED_ROOT_TIGHTEN_COMMAND);

// login.sh's environment without a terminal; status checks also skip nonessential traffic, like renew.sh.
function claudeCommand(container, args) {
  const quiet = args === "auth status --json" ? " CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1" : "";
  return `docker exec -w /data/account/empty ${validateDockerObjectId(container?.Id, "Claude companion")} env -i HOME=/home/claude USER=claude LOGNAME=claude CLAUDE_CONFIG_DIR=/data/account/config PATH=/usr/bin:/bin:/usr/sbin:/sbin TMPDIR=/tmp${quiet} /opt/claude/${CANDIDATE_VERSION}/claude ${args}`;
}

function requireReview(review) {
  if (!review || typeof review !== "object" || Array.isArray(review) || Object.keys(review).length !== REVIEW_FIELDS.length ||
    REVIEW_FIELDS.some((field) => review[field] !== true)) {
    throw Object.assign(new Error("Answer yes to all four Claude review questions first. Nothing was changed."), { statusCode: 400 });
  }
}

function validateMarker(marker) {
  const keys = ["schemaVersion", "kind", "installId", "network", "arch", "models", "tokenSha256", "files"];
  const files = Object.keys(FIXED_FILES);
  if (!marker || typeof marker !== "object" || Array.isArray(marker) || Object.keys(marker).length !== keys.length ||
    keys.some((key) => !Object.hasOwn(marker, key)) || marker.schemaVersion !== 1 || marker.kind !== "relmio-claude-companion" ||
    typeof marker.tokenSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(marker.tokenSha256) ||
    !marker.files || typeof marker.files !== "object" || Object.keys(marker.files).length !== files.length ||
    files.some((path) => !Object.hasOwn(marker.files, path) || !/^[a-f0-9]{64}$/u.test(marker.files[path]))) throw fail();
  validateInstallId(marker.installId);
  reviewClaudeCompanion({ network: marker.network, arch: marker.arch, models: marker.models });
  return marker;
}

function parseListing(text) {
  const [head, ...lines] = text.split("\n");
  if (head === "absent" && lines.length === 0) return "absent";
  const root = /^root (\d+):(\d+) ([0-7]{3,4})$/u.exec(head);
  if (!root) return null;
  const entries = new Map();
  for (const line of lines) {
    const match = /^([A-Za-z0-9_.][A-Za-z0-9_./-]*) ([a-z]) (\d+:\d+) ([0-7]{3,4}) (\d+) (\d+)$/u.exec(line);
    if (!match || entries.has(match[1])) return null;
    entries.set(match[1], { type: match[2], owner: match[3], mode: match[4], links: Number(match[5]), size: Number(match[6]) });
  }
  return { uid: root[1], gid: root[2], mode: root[3], entries };
}

async function inspectOptional(remote, type, name) {
  // Inspect only the exact fixed name, never a guessed production resource.
  const result = await remote.exec(`docker ${type} inspect ${validateDockerName(name)}`);
  if (result.code !== 0) {
    const list = await run(remote, type === "container" ? "docker ps -a --format '{{.Names}}'" : "docker volume ls --format '{{.Name}}'");
    if (list.split("\n").includes(name)) throw fail();
    return null;
  }
  const values = parse(result.stdout);
  if (!Array.isArray(values) || values.length !== 1) throw fail();
  return values[0];
}

// Created by Compose from this folder, whether by the wizard or by hand with the CLI bundle.
function companionOf(container) {
  const labels = container?.Config?.Labels ?? {};
  return container?.Name === `/${NAME}` && labels["com.relmio.managed"] === "claude-companion" &&
    labels["com.docker.compose.project"] === NAME && labels["com.docker.compose.service"] === NAME &&
    labels["com.docker.compose.project.working_dir"] === ROOT;
}

function composeVolume(volume) {
  return volume?.Name === VOLUME && volume.Labels?.["com.docker.compose.project"] === NAME &&
    volume.Labels["com.docker.compose.volume"] === "claude-account";
}

// Docker reports Running: true while a container restarts, so the restart flags are read first.
// Health "starting" covers the first minute after every start and counts as running.
function containerState(container) {
  if (!container) return "missing";
  const state = container.State ?? {};
  if (state.Restarting === true || state.Status === "restarting") return "restarting";
  if (state.Running !== true) return "stopped";
  return state.Health?.Status === "unhealthy" ? "unhealthy" : "running";
}

// Commands can run inside a companion that is up, whether or not its health check passes.
const reachable = (container) => ["running", "unhealthy"].includes(containerState(container));
const NOT_RUNNING = "The Claude companion is stopped or restarting. If its approval expired, renew it. Otherwise inspect its logs and follow the documented lock recovery steps. Replacing it keeps the sign-in and any account lock.";

// The template's hardening, this installation's key hash and network. `running` also needs a companion
// that can run commands: a stopped or restarting one is a state to repair, with its own sentence.
function attestCompanion(container, marker, { running = true } = {}) {
  const host = container?.HostConfig;
  const networks = container?.NetworkSettings?.Networks ?? {};
  const mounts = Array.isArray(container?.Mounts)
    ? container.Mounts.filter((mount) => !(mount.Type === "tmpfs" && ["/tmp", "/home/claude"].includes(mount.Destination)))
    : [];
  const account = mounts.find((mount) => mount.Type === "volume");
  const launch = mounts.find((mount) => mount.Type === "bind");
  if (!companionOf(container) || !container.Config.Env?.includes(`RELMIO_CLAUDE_CHAT_TOKEN_SHA256=${marker.tokenSha256}`) ||
    container.Config.Image !== IMAGE || container.Config.User !== "10001:10001" ||
    Object.keys(networks).length !== 1 || !networks[marker.network]?.Aliases?.includes(CLAUDE_COMPANION.alias) ||
    Object.values(container.NetworkSettings?.Ports ?? {}).some((value) => value !== null) || Object.keys(host?.PortBindings ?? {}).length ||
    host?.ReadonlyRootfs !== true || !host.CapDrop?.includes("ALL") || !host.SecurityOpt?.includes("no-new-privileges:true") ||
    mounts.length !== 2 || account?.Name !== VOLUME || account.Destination !== "/data/account" || account.RW !== true ||
    launch?.Source !== `${ROOT}/launch.json` || launch.Destination !== "/run/relmio/launch.json" || launch.RW !== false) {
    throw fail();
  }
  if (running && !reachable(container)) throw new Error(NOT_RUNNING);
}

// Anything Relmio cannot recognize is "invalid" and refused; only Docker or SSH failures throw.
async function inspectClaude(remote, now) {
  const listing = parseListing(await run(remote, LISTING));
  const container = await inspectOptional(remote, "container", NAME);
  const volume = await inspectOptional(remote, "volume", VOLUME);
  const invalid = { state: "invalid", container, volume, marker: null, launchConfig: null, complete: false };
  if (listing === "absent") {
    if (container || (volume && !composeVolume(volume))) return invalid;
    return { ...invalid, state: "absent", existingLogin: volume !== null };
  }
  if (!listing || (container && !companionOf(container)) || (volume && !composeVolume(volume))) return invalid;
  const { entries } = listing;
  if (!entries.has(MARKER_NAME)) {
    // A root-owned folder is not enough: a non-root or group/other-writable entry could be swapped for a symlink before CLEAR_BUNDLE.
    // renew.sh leaves launch.json owned by 10001:10001, so that one entry may keep it.
    const manual = container !== null && listing.uid === "0" && (Number.parseInt(listing.mode, 8) & 0o022) === 0 &&
      [...entries].every(([path, entry]) => MANUAL_TYPES.get(path) === entry.type && (Number.parseInt(entry.mode, 8) & 0o022) === 0 &&
        (entry.owner === "0:0" || (path === "launch.json" && entry.owner === "10001:10001")));
    return { ...invalid, state: manual ? "manual" : "invalid" };
  }
  if (`${listing.uid}:${listing.gid}` !== "0:0" || listing.mode !== "700" || [...entries].some(([path, entry]) => {
    const spec = ENTRY_SPEC.get(path);
    return !spec || spec.type !== entry.type || spec.owner !== entry.owner || spec.mode !== entry.mode ||
      (entry.type === "f" && (entry.links !== 1 || ([MARKER_NAME, "launch.json"].includes(path) && entry.size > MAX_FILE_BYTES)));
  })) return invalid;
  let marker;
  const markerText = await run(remote, `cat -- ${MARKER}`);
  try { marker = validateMarker(JSON.parse(markerText)); } catch { return invalid; }
  const present = Object.keys(FIXED_FILES).filter((path) => entries.has(path));
  if (present.length) {
    const sums = new Map((await run(remote, `cd ${ROOT} && sha256sum -- ${present.join(" ")}`)).split("\n")
      .map((line) => /^([a-f0-9]{64}) {2}(\S+)$/u.exec(line)).filter(Boolean).map((match) => [match[2], match[1]]));
    if (present.some((path) => sums.get(path) !== marker.files[path])) return invalid;
  }
  let launchConfig = null;
  if (entries.has("launch.json")) {
    const text = await run(remote, `cat -- ${ROOT}/launch.json`);
    try {
      const config = JSON.parse(text);
      renewClaudeLaunchConfig(config, now);
      if (config.executableSha256 === claudeCompanionPin(marker.arch).executableSha256 &&
        JSON.stringify(config.models) === JSON.stringify(marker.models)) launchConfig = config;
    } catch { /* An unreadable approval can only be replaced. */ }
  }
  const complete = present.length === Object.keys(FIXED_FILES).length && entries.has("launch.json");
  return { ...invalid, state: "installed", marker, launchConfig, complete };
}

function publicPin(arch) {
  const { version, platform, executableSha256 } = claudeCompanionPin(arch);
  return { version, arch, platform, executableSha256 };
}

export function vpsClaudeLoginCommand({ host, port, username }) {
  const safePort = validatePort(port);
  return `ssh -t ${safePort === 22 ? "" : `-p ${safePort} `}${validateUsername(username)}@${validateHostname(host)} sh ${ROOT}/login.sh`;
}

export async function getVpsClaudeStatus({ remote, now = Date.now }) {
  const status = await inspectClaude(remote, now);
  const { marker, launchConfig } = status;
  const machine = marker ? null : await run(remote, "uname -m");
  let candidate = null;
  try { candidate = publicPin(marker?.arch ?? claudeArchFromMachine(machine)); } catch { /* Install refuses this architecture. */ }
  const expiresAt = launchConfig?.qualification.expiresAt ?? null;
  return {
    state: status.state,
    ...(status.existingLogin ? { existingLogin: true } : {}),
    installation: status.state === "installed" ? {
      network: marker.network, arch: marker.arch, models: [...marker.models], expiresAt,
      approvalExpired: expiresAt === null || Date.parse(expiresAt) <= now(),
      container: containerState(status.container), endpoint: CLAUDE_COMPANION.endpoint,
    } : null,
    candidate,
  };
}

async function requirePrecheck(remote) {
  const precheck = await remote.exec(PRECHECK_COMMAND);
  if (precheck.code !== 0 || !["new", "managed"].includes(precheck.stdout.trim())) {
    throw new Error("The VPS Relmio folder is not one Relmio recognizes, so the Claude companion cannot be placed there. Nothing was changed.");
  }
}

async function boundary(remote, containerName, networkName, companionId) {
  const container = validateDockerName(containerName);
  const network = validateDockerName(networkName);
  const [id, running, networks] = (await run(remote, `docker container inspect ${container} --format '{{json .Id}}|{{json .State.Running}}|{{json .NetworkSettings.Networks}}'`)).split("|");
  const n8n = { id: parse(id), running: parse(running), networks: parse(networks) };
  if (n8n.running !== true || !n8n.networks?.[network]) throw new Error("The selected n8n container or network changed. Review a fresh plan.");
  const net = parse(await run(remote, `docker network inspect ${network} --format '{{json .}}'`));
  validateDockerObjectId(n8n.id, "n8n container");
  validateDockerObjectId(net.Id, "network");
  if (n8n.networks[network].NetworkID !== net.Id || net.Name !== network) throw fail();
  if (net.Internal !== false) throw new Error("The selected n8n network has no outbound route. Choose a network with internet access. Nothing was changed.");
  for (const [connected, item] of Object.entries(net.Containers ?? {})) {
    validateDockerObjectId(connected, "connected container");
    if (connected === companionId) continue;
    const aliases = parse(await run(remote, `docker container inspect ${connected} --format '{{json .NetworkSettings.Networks}}'`))?.[network]?.Aliases ?? [];
    if ([item.Name, ...aliases].includes(CLAUDE_COMPANION.alias)) {
      throw new Error("Another container already answers to relmio-claude on this n8n network. Nothing was changed.");
    }
  }
  return { containerId: n8n.id, networkId: net.Id };
}

async function readAuthStatus(remote, container) {
  const result = await remote.exec(claudeCommand(container, "auth status --json"));
  // Claude reports a missing login with exit 1 and JSON; all other failures stay errors.
  if (![0, 1].includes(result.code)) {
    throw new Error("The Claude companion could not be checked. Check that it is running.");
  }
  return parseClaudeAuthStatus(result.stdout);
}

/** Read-only review. Every write waits for applyVpsClaude after the owner's final confirmation. */
export async function reviewVpsClaude({ remote, action, containerName = null, networkName = null, models, review, now = Date.now }) {
  if (!ACTIONS.includes(action)) throw new TypeError("Choose a supported Claude action.");
  const approves = ["install", "replace", "renew"].includes(action);
  const deploys = action === "install" || action === "replace";
  if (approves) requireReview(review);
  if (deploys) {
    validateDockerName(containerName);
    validateDockerName(networkName);
    if (!validModels(models)) throw new Error(MODEL_RULES);
  }
  const status = await inspectClaude(remote, now);
  if (!ALLOWED_STATES[action].includes(status.state)) throw new Error("Claude is not ready for that action. Refresh its status first.");
  const plan = {
    action, installDirectory: ROOT, endpoint: CLAUDE_COMPANION.endpoint, existingN8nChanges: [], restarts: 0,
    publishedPorts: [], operationLockPath: LOCK, installId: status.marker?.installId ?? null,
    containerName: null, networkName: null, containerId: null, networkId: null,
    ...(approves ? { review: Object.fromEntries(REVIEW_FIELDS.map((field) => [field, true])) } : {}),
  };
  if (deploys) {
    await requirePrecheck(remote);
    const selection = await boundary(remote, containerName, networkName,
      status.container ? validateDockerObjectId(status.container.Id, "Claude companion") : null);
    const arch = claudeArchFromMachine(await run(remote, "uname -m"));
    const bundle = await buildClaudeCompanionBundle({ network: networkName, arch, models, now });
    return {
      ...plan, containerName, networkName, ...selection, network: networkName, arch, models: bundle.review.models,
      version: bundle.review.version, executableSha256: bundle.review.executableSha256, expiresAt: bundle.expiresAt,
      keepsClaudeLogin: action === "replace" || status.existingLogin === true,
    };
  }
  const { marker } = status;
  const pin = claudeCompanionPin(marker.arch);
  let expiresAt = null;
  if (action !== "remove") {
    if (!status.complete) throw new Error("This Claude companion is incomplete. Replace it first.");
    attestCompanion(status.container, marker, { running: action === "sign-out" });
  }
  if (action === "renew") {
    if (!status.launchConfig) throw new Error("This Claude approval cannot be renewed. Replace the companion to write a fresh one.");
    expiresAt = renewClaudeLaunchConfig(status.launchConfig, now).qualification.expiresAt;
  }
  return {
    ...plan, network: marker.network, arch: marker.arch, models: [...marker.models], version: pin.version,
    executableSha256: pin.executableSha256, expiresAt, keepsClaudeLogin: action === "renew",
  };
}
async function rollbackDeploy(remote, { keepVolume }) {
  const container = await inspectOptional(remote, "container", NAME);
  if (container) {
    if (!companionOf(container)) throw fail();
    await run(remote, `docker rm -f ${validateDockerObjectId(container.Id, "Claude companion")}`);
  }
  if (!keepVolume) {
    const volume = await inspectOptional(remote, "volume", VOLUME);
    if (volume) {
      if (!composeVolume(volume)) throw fail();
      await run(remote, `docker volume rm ${VOLUME}`);
    }
  }
  const listing = parseListing(await run(remote, LISTING));
  if (listing === "absent") return;
  if (!listing) throw new Error("Claude cleanup could not read the companion folder.");
  await run(remote, REMOVE_ROOT);
  if (await inspectOptional(remote, "container", NAME)) throw new Error("The Claude container remains.");
  if (!keepVolume && await inspectOptional(remote, "volume", VOLUME)) throw new Error("The Claude login volume remains.");
  if (parseListing(await run(remote, LISTING)) !== "absent") throw new Error("The Claude companion files remain.");
}


async function deploy(remote, plan, build, status, { now, entropy }) {
  const selection = await boundary(remote, plan.containerName, plan.networkName,
    status.container ? validateDockerObjectId(status.container.Id, "Claude companion") : null);
  if (selection.containerId !== plan.containerId || selection.networkId !== plan.networkId ||
    claudeArchFromMachine(await run(remote, "uname -m")) !== plan.arch) throw fail();
  const bundle = await buildClaudeCompanionBundle({ network: plan.networkName, arch: plan.arch, models: plan.models, now });
  if (bundle.files.length !== Object.keys(FIXED_FILES).length + 1 ||
    bundle.files.some((file) => ENTRY_SPEC.get(file.path)?.mode !== file.mode.toString(8))) throw fail();
  const id = entropy(16);
  if (!Buffer.isBuffer(id) || id.length !== 16) throw fail();
  const marker = validateMarker({
    schemaVersion: 1, kind: "relmio-claude-companion", installId: id.toString("hex"), network: plan.networkName,
    arch: plan.arch, models: bundle.review.models, tokenSha256: bundle.keySha256,
    files: Object.fromEntries(bundle.files.filter((file) => file.path !== "launch.json").map((file) => [file.path, hash(file.contents)])),
  });
  // A later failure removes this attempt. Replace keeps the login volume.
  let wrote = false;
  try {
    await run(remote, plan.action === "install"
      ? `${parentGuard} && umask 077 && mkdir ${ROOT}`
      : `${rootGuard} && cd ${ROOT} && ${CLEAR_BUNDLE} && chmod 0700 ${ROOT}`);
    wrote = true;
    await remote.upload(MARKER, `${JSON.stringify(marker)}\n`, 0o600);
    await run(remote, `${rootGuard} && cd ${ROOT} && mkdir -m 0755 ${DIRECTORIES.join(" ")}`);
    for (const file of bundle.files) await remote.upload(`${ROOT}/${file.path}`, file.contents, file.mode);
    const readable = bundle.files.filter((file) => file.mode === 0o644).map((file) => file.path);
    await run(remote, `${rootGuard} && cd ${ROOT} && chown -R 0:0 -- . && chmod 0644 -- ${readable.join(" ")} && chmod 0600 -- ${MARKER_NAME} .env launch.json && chown -h 10001:10001 -- launch.json`);
    const written = await inspectClaude(remote, now);
    if (written.state !== "installed" || written.marker.installId !== marker.installId || !written.complete || !written.launchConfig) throw fail();
    await run(remote, `${COMPOSE} config --quiet`);
    await build(`${COMPOSE} build ${NAME}`);
    // The healthcheck runs every 60 seconds, so the first healthy report can take about a minute.
    const up = await remote.exec(`${COMPOSE} up -d --wait --wait-timeout 180 --no-build --force-recreate ${NAME}`);
    if (up.code !== 0) throw new Error("The Claude companion did not become healthy. Check its status, then replace or remove it.");
    const container = await inspectOptional(remote, "container", NAME);
    attestCompanion(container, marker, { running: false });
    if (containerState(container) !== "running" || container.State.Health?.Status !== "healthy") throw fail();
    return { state: "installed", clientKey: bundle.key, expiresAt: bundle.expiresAt };
  } catch (error) {
    if (!wrote) throw error;
    try { await rollbackDeploy(remote, { keepVolume: plan.keepsClaudeLogin === true }); }
    catch { throw new Error(`Claude installation failed and automatic cleanup could not be confirmed. ${error.message}`); }
    throw error;
  }
}

async function renew(remote, status, now) {
  // An expired approval keeps the launcher in a restart loop, so its login can only be checked while it runs.
  if (reachable(status.container)) {
    const auth = await readAuthStatus(remote, status.container);
    if (auth.loggedIn !== true || auth.authMethod !== "claude.ai") {
      throw new Error("The companion is not signed in to a Claude plan. Run the sign-in command, then renew.");
    }
  }
  const renewed = renewClaudeLaunchConfig(status.launchConfig, now);
  await tightenSharedFolder(remote);
  await run(remote, createVpsClaudeLaunchWriteCommand(), `${JSON.stringify(renewed, null, 2)}\n`);
  await run(remote, `${COMPOSE} restart ${NAME}`);
  return { state: "installed", expiresAt: renewed.qualification.expiresAt };
}

async function signOut(remote, status) {
  await tightenSharedFolder(remote);
  const logout = await remote.exec(claudeCommand(status.container, "auth logout"));
  if (logout.code !== 0) throw new Error("Claude sign-out did not finish. Check its status and try again.");
  if ((await readAuthStatus(remote, status.container)).loggedIn) {
    throw new Error("Claude still reports a sign-in. Check its status and try again.");
  }
  return { state: "installed", loggedIn: false };
}

async function remove(remote, status) {
  await tightenSharedFolder(remote);
  if (status.container) await run(remote, `docker rm -f ${validateDockerObjectId(status.container.Id, "Claude companion")}`);
  if (status.volume) await run(remote, `docker volume rm ${VOLUME}`);
  await run(remote, REMOVE_ROOT);
  return { state: "absent" };
}

/** Applies a reviewed plan after the owner's final confirmation, under the VPS Claude operation lock. */
export async function applyVpsClaude({ remote, plan, confirmed }, { now = Date.now, entropy = randomBytes } = {}) {
  if (confirmed !== true || !ACTIONS.includes(plan?.action)) throw new Error("Confirm the reviewed Claude action first.");
  if (["install", "replace", "renew"].includes(plan.action)) requireReview(plan.review);
  const deploys = plan.action === "install" || plan.action === "replace";
  if (deploys) {
    if (!validModels(plan.models)) throw fail();
    await requirePrecheck(remote);
    await run(remote, `${SHARED_ROOT_COMMAND} && if [ ! -e ${SHARED_ROOT_MARKER_PATH} ]; then (set -C; printf '%s\\n' ${quote(SHARED_ROOT_MARKER_CONTENT.trim())} > ${SHARED_ROOT_MARKER_PATH}); fi`);
  }
  return withVpsOperationLock(remote, LOCK, async (build) => {
    const status = await inspectClaude(remote, now);
    // keepsClaudeLogin was fixed at review; a login volume that appeared since must not be rolled back, so drift fails closed.
    if (!ALLOWED_STATES[plan.action].includes(status.state) || (status.marker?.installId ?? null) !== plan.installId ||
      (deploys && plan.keepsClaudeLogin !== (plan.action === "replace" || status.existingLogin === true))) throw fail();
    if (deploys) return deploy(remote, plan, build, status, { now, entropy });
    if (plan.action === "remove") return remove(remote, status);
    if (!status.complete || status.marker.network !== plan.network) throw fail();
    attestCompanion(status.container, status.marker, { running: plan.action === "sign-out" });
    if (plan.action === "sign-out") return signOut(remote, status);
    if (!status.launchConfig) throw fail();
    return renew(remote, status, now);
  });
}

export async function getVpsClaudeAuthStatus({ remote, now = Date.now }) {
  const status = await inspectClaude(remote, now);
  if (status.state !== "installed") throw new Error("Claude is not installed by this wizard. Refresh its status first.");
  attestCompanion(status.container, status.marker);
  return await readAuthStatus(remote, status.container);
}

export async function discoverVpsClaudeModels({ remote, clientKey, now = Date.now }) {
  if (typeof clientKey !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(clientKey)) {
    throw new TypeError("Enter the Claude companion key shown when this companion was installed.");
  }
  const status = await inspectClaude(remote, now);
  if (status.state !== "installed" || status.marker.tokenSha256 !== hash(clientKey)) {
    throw new Error("That key does not belong to this Claude companion.");
  }
  attestCompanion(status.container, status.marker);
  const result = parse(await run(remote, `docker exec -i ${validateDockerObjectId(status.container.Id, "Claude companion")} node -e ${quote(VPS_CLAUDE_MODELS_PROBE)}`, clientKey));
  if (result?.status === 401) throw new Error("The Claude companion did not accept this key.");
  if (result?.status !== 200 || !validModels(result.models)) throw new Error("The Claude companion did not answer. Check its status.");
  return { models: result.models };
}