// 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 }; }