From 3b4843e7b19ef5ae4957b2ec0d4f9977cbc0ab74 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sat, 4 Apr 2026 23:13:58 -0700 Subject: [PATCH 01/26] Queue updater installs across restarts --- bin/alphaclaw.js | 76 +++++-- lib/public/js/app.js | 3 + lib/public/js/components/gateway.js | 9 +- lib/public/js/components/general/index.js | 2 + .../js/components/routes/general-route.js | 2 + .../js/components/routes/watchdog-route.js | 2 + lib/public/js/components/sidebar.js | 27 ++- .../js/components/update-modal-helpers.js | 12 ++ lib/public/js/components/update-modal.js | 3 +- .../js/components/watchdog-tab/index.js | 2 + .../js/hooks/use-app-shell-controller.js | 42 +++- lib/public/js/lib/api.js | 35 ++++ lib/server/alphaclaw-version.js | 157 +++------------ lib/server/openclaw-version.js | 189 ++++++------------ lib/server/pending-alphaclaw-update.js | 71 +++++++ lib/server/pending-openclaw-update.js | 71 +++++++ lib/server/routes/system.js | 7 +- tests/frontend/api.test.js | 49 +++++ tests/frontend/update-modal-helpers.test.js | 35 ++++ tests/server/alphaclaw-version.test.js | 146 ++++++++------ tests/server/openclaw-version.test.js | 180 ++++++++++------- tests/server/pending-alphaclaw-update.test.js | 102 ++++++++++ tests/server/pending-openclaw-update.test.js | 100 +++++++++ tests/server/routes-system.test.js | 71 +++++++ 24 files changed, 962 insertions(+), 431 deletions(-) create mode 100644 lib/public/js/components/update-modal-helpers.js create mode 100644 lib/server/pending-alphaclaw-update.js create mode 100644 lib/server/pending-openclaw-update.js create mode 100644 tests/frontend/update-modal-helpers.test.js create mode 100644 tests/server/pending-alphaclaw-update.test.js create mode 100644 tests/server/pending-openclaw-update.test.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 3e5cc122..d49b6188 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -13,6 +13,12 @@ const { buildSecretReplacements } = require("../lib/server/helpers"); const { migrateManagedInternalFiles, } = require("../lib/server/internal-files-migration"); +const { + applyPendingAlphaclawUpdate, +} = require("../lib/server/pending-alphaclaw-update"); +const { + applyPendingOpenclawUpdate, +} = require("../lib/server/pending-openclaw-update"); const kUsageTrackerPluginPath = path.resolve( __dirname, @@ -125,6 +131,26 @@ const resolveGithubRepoPath = (value) => .replace(/^git@github\.com:/, "") .replace(/^https:\/\/github\.com\//, "") .replace(/\.git$/, ""); +const isContainerRuntime = () => + process.env.RAILWAY_ENVIRONMENT || + process.env.RENDER || + process.env.FLY_APP_NAME || + fs.existsSync("/.dockerenv"); +const restartAfterPendingUpdate = () => { + if (isContainerRuntime()) { + console.log("[alphaclaw] Restarting via container crash (exit 1)..."); + process.exit(1); + } + console.log("[alphaclaw] Spawning new process and exiting..."); + const { spawn } = require("child_process"); + const child = spawn(process.argv[0], process.argv.slice(1), { + detached: true, + stdio: "inherit", + env: process.env, + }); + child.unref(); + process.exit(0); +}; // --------------------------------------------------------------------------- // 1. Resolve root directory (before requiring any lib/ modules) @@ -155,37 +181,47 @@ const { hourlyGitSyncPath } = migrateManagedInternalFiles({ console.log(`[alphaclaw] Root directory: ${rootDir}`); // Check for pending update marker (written by the update endpoint before restart). -// In environments where the container filesystem is ephemeral (Railway, etc.), -// the npm install from the update endpoint is lost on restart. This re-runs it -// from the fresh container using the persistent volume marker. +// We perform a real npm install during boot rather than copy-merging node_modules +// while the old process is still running. That keeps AlphaClaw and any newly +// pinned OpenClaw version in a coherent npm tree. const pendingUpdateMarker = path.join(rootDir, ".alphaclaw-update-pending"); if (fs.existsSync(pendingUpdateMarker)) { - console.log( - "[alphaclaw] Pending update detected, installing @chrysb/alphaclaw@latest...", - ); const alphaPkgRoot = path.resolve(__dirname, ".."); const nmIndex = alphaPkgRoot.lastIndexOf( `${path.sep}node_modules${path.sep}`, ); const installDir = nmIndex >= 0 ? alphaPkgRoot.slice(0, nmIndex) : alphaPkgRoot; - try { - execSync( - "npm install @chrysb/alphaclaw@latest --omit=dev --prefer-online", - { - cwd: installDir, - stdio: "inherit", - timeout: 180000, - }, - ); - fs.unlinkSync(pendingUpdateMarker); - console.log("[alphaclaw] Update applied successfully"); - } catch (e) { - console.log(`[alphaclaw] Update install failed: ${e.message}`); - fs.unlinkSync(pendingUpdateMarker); + const pendingUpdate = applyPendingAlphaclawUpdate({ + execSyncImpl: execSync, + fsModule: fs, + installDir, + logger: console, + markerPath: pendingUpdateMarker, + }); + if (pendingUpdate.installed) { + console.log("[alphaclaw] Restarting to load updated code..."); + restartAfterPendingUpdate(); } } +const pendingOpenclawUpdateMarker = path.join(rootDir, ".openclaw-update-pending"); +if (fs.existsSync(pendingOpenclawUpdateMarker)) { + const alphaPkgRoot = path.resolve(__dirname, ".."); + const nmIndex = alphaPkgRoot.lastIndexOf( + `${path.sep}node_modules${path.sep}`, + ); + const installDir = + nmIndex >= 0 ? alphaPkgRoot.slice(0, nmIndex) : alphaPkgRoot; + applyPendingOpenclawUpdate({ + execSyncImpl: execSync, + fsModule: fs, + installDir, + logger: console, + markerPath: pendingOpenclawUpdateMarker, + }); +} + // --------------------------------------------------------------------------- // 3. Symlink ~/.openclaw -> /.openclaw // --------------------------------------------------------------------------- diff --git a/lib/public/js/app.js b/lib/public/js/app.js index 7a703b7e..4d7ca5d4 100644 --- a/lib/public/js/app.js +++ b/lib/public/js/app.js @@ -202,6 +202,7 @@ const App = () => { onPreviewBrowseFile=${browseActions.handleBrowsePreviewFile} acHasUpdate=${controllerState.acHasUpdate} acLatest=${controllerState.acLatest} + acRestarting=${controllerState.acRestarting} acUpdating=${controllerState.acUpdating} onAcUpdate=${controllerActions.handleAcUpdate} agents=${agentsState.agents} @@ -384,6 +385,7 @@ const App = () => { restartingGateway=${controllerState.restartingGateway} onRestartGateway=${controllerActions.handleGatewayRestart} restartSignal=${controllerState.gatewayRestartSignal} + openclawRestarting=${controllerState.openclawRestarting} openclawUpdateInProgress=${controllerState.openclawUpdateInProgress} onOpenclawVersionActionComplete=${controllerActions.handleOpenclawVersionActionComplete} onOpenclawUpdate=${controllerActions.handleOpenclawUpdate} @@ -419,6 +421,7 @@ const App = () => { restartingGateway=${controllerState.restartingGateway} onRestartGateway=${controllerActions.handleGatewayRestart} restartSignal=${controllerState.gatewayRestartSignal} + openclawRestarting=${controllerState.openclawRestarting} openclawUpdateInProgress=${controllerState.openclawUpdateInProgress} onOpenclawVersionActionComplete=${controllerActions.handleOpenclawVersionActionComplete} onOpenclawUpdate=${controllerActions.handleOpenclawUpdate} diff --git a/lib/public/js/components/gateway.js b/lib/public/js/components/gateway.js index ddab812b..ca4a9230 100644 --- a/lib/public/js/components/gateway.js +++ b/lib/public/js/components/gateway.js @@ -27,6 +27,7 @@ const VersionRow = ({ fetchVersion, applyUpdate, updateInProgress = false, + updateLoadingLabel = "Updating...", onActionComplete = () => {}, }) => { const [checking, setChecking] = useState(false); @@ -236,7 +237,7 @@ const VersionRow = ({ ? updateIdleLabel : "Check updates"} loadingLabel=${isUpdateActionActive - ? "Updating..." + ? updateLoadingLabel : "Checking..."} className="hidden md:inline-flex" /> @@ -250,7 +251,7 @@ const VersionRow = ({ ? updateIdleLabel : "Check updates"} loadingLabel=${isUpdateActionActive - ? "Updating..." + ? updateLoadingLabel : "Checking..."} /> `} @@ -272,7 +273,7 @@ const VersionRow = ({ loading=${updateButtonLoading} warning=${isUpdateActionActive} idleLabel=${updateIdleLabel} - loadingLabel="Updating..." + loadingLabel=${updateLoadingLabel} className="flex-1 h-9 px-3" /> @@ -299,6 +300,7 @@ export const Gateway = ({ onOpenWatchdog, onRepair, repairing = false, + openclawRestarting = false, openclawUpdateInProgress = false, onOpenclawVersionActionComplete = () => {}, onOpenclawUpdate = updateOpenclaw, @@ -443,6 +445,7 @@ export const Gateway = ({ fetchVersion=${fetchOpenclawVersion} applyUpdate=${onOpenclawUpdate} updateInProgress=${openclawUpdateInProgress} + updateLoadingLabel=${openclawRestarting ? "Restarting..." : "Updating..."} onActionComplete=${onOpenclawVersionActionComplete} /> diff --git a/lib/public/js/components/general/index.js b/lib/public/js/components/general/index.js index ba36cd26..5cd0c826 100644 --- a/lib/public/js/components/general/index.js +++ b/lib/public/js/components/general/index.js @@ -28,6 +28,7 @@ export const GeneralTab = ({ restartingGateway = false, onRestartGateway = () => {}, restartSignal = 0, + openclawRestarting = false, openclawUpdateInProgress = false, onOpenclawVersionActionComplete = () => {}, onOpenclawUpdate = () => {}, @@ -54,6 +55,7 @@ export const GeneralTab = ({ onOpenWatchdog=${() => onSwitchTab("watchdog")} onRepair=${actions.handleWatchdogRepair} repairing=${state.repairingWatchdog} + openclawRestarting=${openclawRestarting} openclawUpdateInProgress=${openclawUpdateInProgress} onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} onOpenclawUpdate=${onOpenclawUpdate} diff --git a/lib/public/js/components/routes/general-route.js b/lib/public/js/components/routes/general-route.js index 33721be7..3e03fc3f 100644 --- a/lib/public/js/components/routes/general-route.js +++ b/lib/public/js/components/routes/general-route.js @@ -16,6 +16,7 @@ export const GeneralRoute = ({ restartingGateway = false, onRestartGateway = () => {}, restartSignal = 0, + openclawRestarting = false, openclawUpdateInProgress = false, onOpenclawVersionActionComplete = () => {}, onOpenclawUpdate = () => {}, @@ -37,6 +38,7 @@ export const GeneralRoute = ({ restartingGateway=${restartingGateway} onRestartGateway=${onRestartGateway} restartSignal=${restartSignal} + openclawRestarting=${openclawRestarting} openclawUpdateInProgress=${openclawUpdateInProgress} onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} onOpenclawUpdate=${onOpenclawUpdate} diff --git a/lib/public/js/components/routes/watchdog-route.js b/lib/public/js/components/routes/watchdog-route.js index 1989f4d4..431dcb90 100644 --- a/lib/public/js/components/routes/watchdog-route.js +++ b/lib/public/js/components/routes/watchdog-route.js @@ -11,6 +11,7 @@ export const WatchdogRoute = ({ restartingGateway = false, onRestartGateway = () => {}, restartSignal = 0, + openclawRestarting = false, openclawUpdateInProgress = false, onOpenclawVersionActionComplete = () => {}, onOpenclawUpdate = () => {}, @@ -24,6 +25,7 @@ export const WatchdogRoute = ({ restartingGateway=${restartingGateway} onRestartGateway=${onRestartGateway} restartSignal=${restartSignal} + openclawRestarting=${openclawRestarting} openclawUpdateInProgress=${openclawUpdateInProgress} onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} onOpenclawUpdate=${onOpenclawUpdate} diff --git a/lib/public/js/components/sidebar.js b/lib/public/js/components/sidebar.js index fc4b6ebd..b4899f1a 100644 --- a/lib/public/js/components/sidebar.js +++ b/lib/public/js/components/sidebar.js @@ -1,5 +1,5 @@ import { h } from "preact"; -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import htm from "htm"; import { AddLineIcon, @@ -22,6 +22,7 @@ import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js"; import { UpdateActionButton } from "./update-action-button.js"; import { SidebarGitPanel } from "./sidebar-git-panel.js"; import { UpdateModal } from "./update-modal.js"; +import { createUpdateModalSubmitHandler } from "./update-modal-helpers.js"; import { readUiSettings, updateUiSettings, @@ -110,6 +111,7 @@ export const AppSidebar = ({ onPreviewBrowseFile = () => {}, acHasUpdate = false, acLatest = "", + acRestarting = false, acUpdating = false, onAcUpdate = () => {}, agents = [], @@ -182,6 +184,19 @@ export const AppSidebar = ({ writeUiSettings(settings); }, [browseBottomPanelHeightPx]); + const handleUpdateModalClose = useCallback(() => { + if (acUpdating) return; + setUpdateModalOpen(false); + }, [acUpdating]); + + const handleUpdateModalSubmit = useCallback( + createUpdateModalSubmitHandler({ + onClose: () => setUpdateModalOpen(false), + onUpdate: onAcUpdate, + }), + [onAcUpdate], + ); + const getClampedBrowseBottomPanelHeight = (value) => { const layoutElement = browseLayoutRef.current; if (!layoutElement) return value; @@ -364,7 +379,7 @@ export const AppSidebar = ({ loading=${acUpdating} warning=${true} idleLabel=${`Update to v${acLatest}`} - loadingLabel="Updating..." + loadingLabel=${acRestarting ? "Restarting..." : "Updating..."} className="w-full justify-center" /> ` @@ -489,13 +504,11 @@ export const AppSidebar = ({ <${UpdateModal} visible=${updateModalOpen} - onClose=${() => { - if (acUpdating) return; - setUpdateModalOpen(false); - }} + onClose=${handleUpdateModalClose} version=${acLatest} - onUpdate=${onAcUpdate} + onUpdate=${handleUpdateModalSubmit} updating=${acUpdating} + updateLoadingLabel=${acRestarting ? "Restarting..." : "Updating..."} /> `; diff --git a/lib/public/js/components/update-modal-helpers.js b/lib/public/js/components/update-modal-helpers.js new file mode 100644 index 00000000..e626aed6 --- /dev/null +++ b/lib/public/js/components/update-modal-helpers.js @@ -0,0 +1,12 @@ +export const createUpdateModalSubmitHandler = ({ + onClose = () => {}, + onUpdate = async () => ({ ok: false }), +}) => { + return async () => { + const result = await onUpdate(); + if (result?.ok) { + onClose(); + } + return result; + }; +}; diff --git a/lib/public/js/components/update-modal.js b/lib/public/js/components/update-modal.js index 9e2baf0b..28545cf6 100644 --- a/lib/public/js/components/update-modal.js +++ b/lib/public/js/components/update-modal.js @@ -40,6 +40,7 @@ export const UpdateModal = ({ version = "", onUpdate = () => {}, updating = false, + updateLoadingLabel = "Updating...", }) => { const requestedTag = useMemo(() => getReleaseTagFromVersion(version), [version]); const [loadingNotes, setLoadingNotes] = useState(false); @@ -163,7 +164,7 @@ export const UpdateModal = ({ onClick=${onUpdate} tone="warning" idleLabel=${updateLabel} - loadingLabel="Updating..." + loadingLabel=${updateLoadingLabel} loading=${updating} disabled=${loadingNotes} /> diff --git a/lib/public/js/components/watchdog-tab/index.js b/lib/public/js/components/watchdog-tab/index.js index f65d057a..190b3d0d 100644 --- a/lib/public/js/components/watchdog-tab/index.js +++ b/lib/public/js/components/watchdog-tab/index.js @@ -17,6 +17,7 @@ export const WatchdogTab = ({ restartingGateway = false, onRestartGateway, restartSignal = 0, + openclawRestarting = false, openclawUpdateInProgress = false, onOpenclawVersionActionComplete = () => {}, onOpenclawUpdate, @@ -37,6 +38,7 @@ export const WatchdogTab = ({ watchdogStatus=${state.currentWatchdogStatus} onRepair=${state.onRepair} repairing=${state.isRepairInProgress} + openclawRestarting=${openclawRestarting} openclawUpdateInProgress=${openclawUpdateInProgress} onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete} onOpenclawUpdate=${onOpenclawUpdate} diff --git a/lib/public/js/hooks/use-app-shell-controller.js b/lib/public/js/hooks/use-app-shell-controller.js index e97ff853..9528b89d 100644 --- a/lib/public/js/hooks/use-app-shell-controller.js +++ b/lib/public/js/hooks/use-app-shell-controller.js @@ -5,6 +5,7 @@ import { fetchAuthStatus, fetchAlphaclawVersion, updateAlphaclaw, + waitForAlphaclawRestart, fetchRestartStatus, dismissRestartStatus, restartGateway, @@ -25,6 +26,7 @@ export const useAppShellController = ({ location = "" } = {}) => { const [acLatest, setAcLatest] = useState(null); const [acHasUpdate, setAcHasUpdate] = useState(false); const [acUpdating, setAcUpdating] = useState(false); + const [acRestarting, setAcRestarting] = useState(false); const [restartRequired, setRestartRequired] = useState(false); const [browseRestartRequired, setBrowseRestartRequired] = useState(false); const [restartingGateway, setRestartingGateway] = useState(false); @@ -32,6 +34,7 @@ export const useAppShellController = ({ location = "" } = {}) => { const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000); const [statusPollingGraceElapsed, setStatusPollingGraceElapsed] = useState(false); const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false); + const [openclawRestarting, setOpenclawRestarting] = useState(false); const [statusStreamConnected, setStatusStreamConnected] = useState(false); const [statusStreamStatus, setStatusStreamStatus] = useState(null); const [statusStreamWatchdog, setStatusStreamWatchdog] = useState(null); @@ -241,17 +244,25 @@ export const useAppShellController = ({ location = "" } = {}) => { return { ok: false, error: "OpenClaw update already in progress" }; } setOpenclawUpdateInProgress(true); + setOpenclawRestarting(false); try { const data = await updateOpenclaw(); + if (data?.ok && data?.restarting) { + setOpenclawRestarting(true); + await waitForAlphaclawRestart(); + window.location.reload(); + return { ...data, restartHandled: true }; + } + setOpenclawUpdateInProgress(false); + setOpenclawRestarting(false); return data; - } finally { + } catch (err) { + const message = err.message || "Could not update OpenClaw"; setOpenclawUpdateInProgress(false); - refreshSharedStatuses(); - setTimeout(refreshSharedStatuses, 1200); - setTimeout(refreshSharedStatuses, 3500); - setTimeout(refreshRestartStatus, 1200); + setOpenclawRestarting(false); + return { ok: false, error: message }; } - }, [openclawUpdateInProgress, refreshRestartStatus, refreshSharedStatuses]); + }, [openclawUpdateInProgress]); const handleOpenclawVersionActionComplete = useCallback( ({ type }) => { @@ -263,20 +274,31 @@ export const useAppShellController = ({ location = "" } = {}) => { ); const handleAcUpdate = useCallback(async () => { - if (acUpdating) return; + if (acUpdating) { + return { ok: false, error: "AlphaClaw update already in progress" }; + } setAcUpdating(true); + setAcRestarting(false); try { const data = await updateAlphaclaw(); if (data.ok) { showToast("AlphaClaw updated — restarting...", "success"); - setTimeout(() => window.location.reload(), 5000); + setAcRestarting(true); + await waitForAlphaclawRestart(); + window.location.reload(); + return data; } else { showToast(data.error || "AlphaClaw update failed", "error"); setAcUpdating(false); + setAcRestarting(false); + return data; } } catch (err) { - showToast(err.message || "Could not update AlphaClaw", "error"); + const message = err.message || "Could not update AlphaClaw"; + showToast(message, "error"); setAcUpdating(false); + setAcRestarting(false); + return { ok: false, error: message }; } }, [acUpdating]); @@ -296,12 +318,14 @@ export const useAppShellController = ({ location = "" } = {}) => { state: { acHasUpdate, acLatest, + acRestarting, acUpdating, acVersion, authEnabled, gatewayRestartSignal, isAnyRestartRequired, onboarded, + openclawRestarting, openclawUpdateInProgress, restartingGateway, sharedDoctorStatus, diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index f3cb4f93..64a1b894 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -527,6 +527,41 @@ export async function updateAlphaclaw() { return res.json(); } +const delay = (ms) => + new Promise((resolve) => { + setTimeout(resolve, Math.max(0, Number(ms) || 0)); + }); + +export async function waitForAlphaclawRestart({ + initialDelayMs = 1500, + intervalMs = 1000, + timeoutMs = 60000, +} = {}) { + const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); + await delay(initialDelayMs); + + while (Date.now() <= deadline) { + try { + const headers = new Headers(); + const browserTimeZone = getBrowserTimeZone(); + if (browserTimeZone) { + headers.set(kClientTimeZoneHeader, browserTimeZone); + } + const res = await fetch("/api/auth/status", { + cache: "no-store", + credentials: "same-origin", + headers, + }); + if (res.status < 500) { + return { ok: true }; + } + } catch {} + await delay(intervalMs); + } + + throw new Error("AlphaClaw restart is taking longer than expected"); +} + export async function fetchSyncCron() { const res = await authFetch("/api/sync-cron"); const text = await res.text(); diff --git a/lib/server/alphaclaw-version.js b/lib/server/alphaclaw-version.js index 796e7cec..382eef0f 100644 --- a/lib/server/alphaclaw-version.js +++ b/lib/server/alphaclaw-version.js @@ -1,6 +1,4 @@ -const childProcess = require("child_process"); const fs = require("fs"); -const os = require("os"); const path = require("path"); const https = require("https"); const http = require("http"); @@ -8,7 +6,6 @@ const { kLatestVersionCacheTtlMs, kAlphaclawRegistryUrl, kNpmPackageRoot, - kOpenclawUpdateCopyTimeoutMs, kRootDir, } = require("./constants"); @@ -26,6 +23,9 @@ const isNewerVersion = (latest, current) => { return l.patch > c.patch; }; +const buildAlphaclawInstallSpec = (version = "latest") => + `@chrysb/alphaclaw@${String(version || "").trim() || "latest"}`; + const createAlphaclawVersionService = () => { let kUpdateStatusCache = { latestVersion: null, @@ -108,120 +108,6 @@ const createAlphaclawVersionService = () => { return { latestVersion, hasUpdate }; }; - const findInstallDir = () => { - // Walk up from kNpmPackageRoot to find the consuming project's directory - // (the one with node_modules/@chrysb/alphaclaw). In Docker this is /app. - let dir = kNpmPackageRoot; - while (dir !== path.dirname(dir)) { - const parent = path.dirname(dir); - if ( - path.basename(parent) === "node_modules" || - parent.includes(`${path.sep}node_modules${path.sep}`) - ) { - dir = parent; - continue; - } - const pkgPath = path.join(parent, "package.json"); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - if ( - pkg.dependencies?.["@chrysb/alphaclaw"] || - pkg.devDependencies?.["@chrysb/alphaclaw"] || - pkg.optionalDependencies?.["@chrysb/alphaclaw"] - ) { - return parent; - } - } catch {} - } - dir = parent; - } - // Fallback: if running directly (not from node_modules), use kNpmPackageRoot - return kNpmPackageRoot; - }; - - const installLatestAlphaclaw = () => - new Promise((resolve, reject) => { - const installDir = findInstallDir(); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "alphaclaw-update-"), - ); - - const cleanup = () => { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch {} - }; - - fs.writeFileSync( - path.join(tmpDir, "package.json"), - JSON.stringify({ - private: true, - dependencies: { "@chrysb/alphaclaw": "latest" }, - }), - ); - - const npmEnv = { - ...process.env, - npm_config_update_notifier: "false", - npm_config_fund: "false", - npm_config_audit: "false", - }; - - console.log( - `[alphaclaw] Running: npm install @chrysb/alphaclaw@latest in temp dir (target: ${installDir})`, - ); - childProcess.exec( - "npm install --omit=dev --prefer-online --package-lock=false", - { - cwd: tmpDir, - env: npmEnv, - timeout: 180000, - }, - (err, stdout, stderr) => { - if (err) { - const message = String(stderr || err.message || "").trim(); - console.log( - `[alphaclaw] alphaclaw install error: ${message.slice(0, 200)}`, - ); - cleanup(); - return reject( - new Error( - message || "Failed to install @chrysb/alphaclaw@latest", - ), - ); - } - if (stdout?.trim()) { - console.log( - `[alphaclaw] alphaclaw install stdout: ${stdout.trim().slice(0, 300)}`, - ); - } - - const src = path.join(tmpDir, "node_modules"); - const dest = path.join(installDir, "node_modules"); - childProcess.exec( - `cp -af "${src}/." "${dest}/"`, - { timeout: kOpenclawUpdateCopyTimeoutMs }, - (copyErr) => { - cleanup(); - if (copyErr) { - console.log( - `[alphaclaw] alphaclaw copy error: ${(copyErr.message || "").slice(0, 200)}`, - ); - return reject( - new Error( - `Failed to copy updated AlphaClaw files: ${copyErr.message}`, - ), - ); - } - console.log("[alphaclaw] alphaclaw install completed"); - resolve({ stdout: stdout?.trim(), stderr: stderr?.trim() }); - }, - ); - }, - ); - }); - const isContainer = () => process.env.RAILWAY_ENVIRONMENT || process.env.RENDER || @@ -277,18 +163,33 @@ const createAlphaclawVersionService = () => { kUpdateInProgress = true; const previousVersion = readAlphaclawVersion(); try { - await installLatestAlphaclaw(); - // Write marker to persistent volume so the update survives container recreation - const markerPath = path.join(kRootDir, ".alphaclaw-update-pending"); + let targetVersion = "latest"; try { - fs.writeFileSync( - markerPath, - JSON.stringify({ from: previousVersion, ts: Date.now() }), + const updateStatus = await readAlphaclawUpdateStatus({ refresh: true }); + if (updateStatus.latestVersion) { + targetVersion = updateStatus.latestVersion; + } + } catch (error) { + console.log( + `[alphaclaw] Could not resolve exact AlphaClaw version before restart: ${error.message || "unknown error"}`, ); - console.log(`[alphaclaw] Update marker written to ${markerPath}`); - } catch (e) { - console.log(`[alphaclaw] Could not write update marker: ${e.message}`); } + + const spec = buildAlphaclawInstallSpec(targetVersion); + // Write marker to persistent volume so the update survives container recreation + const markerPath = path.join(kRootDir, ".alphaclaw-update-pending"); + fs.writeFileSync( + markerPath, + JSON.stringify({ + from: previousVersion, + to: targetVersion, + spec, + ts: Date.now(), + }), + ); + console.log( + `[alphaclaw] Update marker written to ${markerPath} for ${spec}`, + ); kUpdateStatusCache = { latestVersion: null, hasUpdate: false, @@ -299,15 +200,17 @@ const createAlphaclawVersionService = () => { body: { ok: true, previousVersion, + targetVersion: targetVersion === "latest" ? null : targetVersion, restarting: true, }, }; } catch (err) { - kUpdateInProgress = false; return { status: 500, body: { ok: false, error: err.message || "Failed to update AlphaClaw" }, }; + } finally { + kUpdateInProgress = false; } }; diff --git a/lib/server/openclaw-version.js b/lib/server/openclaw-version.js index 37de97cd..1994e3b0 100644 --- a/lib/server/openclaw-version.js +++ b/lib/server/openclaw-version.js @@ -1,12 +1,10 @@ -const { exec, execSync } = require("child_process"); +const { execSync } = require("child_process"); const fs = require("fs"); -const os = require("os"); const path = require("path"); const { kVersionCacheTtlMs, kLatestVersionCacheTtlMs, - kNpmPackageRoot, - kOpenclawUpdateCopyTimeoutMs, + kRootDir, } = require("./constants"); const { normalizeOpenclawVersion } = require("./helpers"); const { parseJsonObjectFromNoisyOutput } = require("./utils/json"); @@ -24,6 +22,9 @@ const createOpenclawVersionService = ({ }; let kOpenclawUpdateInProgress = false; + const buildOpenclawInstallSpec = (version = "latest") => + `openclaw@${String(version || "").trim() || "latest"}`; + const readOpenclawVersion = () => { const now = Date.now(); if ( @@ -87,118 +88,6 @@ const createOpenclawVersionService = ({ } }; - const findInstallDir = () => { - // Resolve the consumer app root (for example /app in Docker), not this package directory. - let dir = kNpmPackageRoot; - while (dir !== path.dirname(dir)) { - const parent = path.dirname(dir); - if ( - path.basename(parent) === "node_modules" || - parent.includes(`${path.sep}node_modules${path.sep}`) - ) { - dir = parent; - continue; - } - const pkgPath = path.join(parent, "package.json"); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - if ( - pkg.dependencies?.["@chrysb/alphaclaw"] || - pkg.devDependencies?.["@chrysb/alphaclaw"] || - pkg.optionalDependencies?.["@chrysb/alphaclaw"] - ) { - return parent; - } - } catch {} - } - dir = parent; - } - return kNpmPackageRoot; - }; - - // Install to a temp directory, then copy into the real node_modules. - // Running `npm install` directly in the app dir causes EBUSY on Docker - // because npm tries to rename directories that the running process holds open. - // Copying individual files (cp -af) avoids the rename syscall entirely. - const installLatestOpenclaw = () => - new Promise((resolve, reject) => { - const installDir = findInstallDir(); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-update-"), - ); - const cleanup = () => { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch {} - }; - - fs.writeFileSync( - path.join(tmpDir, "package.json"), - JSON.stringify({ - private: true, - dependencies: { openclaw: "latest" }, - }), - ); - - const npmEnv = { - ...process.env, - npm_config_update_notifier: "false", - npm_config_fund: "false", - npm_config_audit: "false", - }; - - console.log( - `[alphaclaw] Running: npm install openclaw@latest in temp dir (target: ${installDir})`, - ); - exec( - "npm install --omit=dev --prefer-online --package-lock=false", - { cwd: tmpDir, env: npmEnv, timeout: 180000 }, - (installErr, stdout, stderr) => { - if (installErr) { - const message = String(stderr || installErr.message || "").trim(); - console.log( - `[alphaclaw] openclaw install error: ${message.slice(0, 200)}`, - ); - cleanup(); - return reject( - new Error(message || "Failed to install openclaw@latest"), - ); - } - if (stdout?.trim()) { - console.log( - `[alphaclaw] openclaw install stdout: ${stdout.trim().slice(0, 300)}`, - ); - } - - const src = path.join(tmpDir, "node_modules"); - const dest = path.join(installDir, "node_modules"); - exec( - `cp -af "${src}/." "${dest}/"`, - { timeout: kOpenclawUpdateCopyTimeoutMs }, - (cpErr) => { - cleanup(); - if (cpErr) { - console.log( - `[alphaclaw] openclaw copy error: ${(cpErr.message || "").slice(0, 200)}`, - ); - return reject( - new Error( - `Failed to copy updated openclaw files: ${cpErr.message}`, - ), - ); - } - console.log("[alphaclaw] openclaw install completed"); - resolve({ - stdout: stdout?.trim() || "", - stderr: stderr?.trim() || "", - }); - }, - ); - }, - ); - }); - const getVersionStatus = async (refresh) => { const currentVersion = readOpenclawVersion(); try { @@ -228,27 +117,67 @@ const createOpenclawVersionService = ({ kOpenclawUpdateInProgress = true; const previousVersion = readOpenclawVersion(); try { - await installLatestOpenclaw(); - kOpenclawVersionCache = { value: null, fetchedAt: 0 }; - const currentVersion = readOpenclawVersion(); - const { latestVersion, hasUpdate } = readOpenclawUpdateStatus({ - refresh: true, - }); - let restarted = false; - if (isOnboarded()) { - restartGateway(); - restarted = true; + let latestVersion = null; + let hasUpdate = false; + try { + const updateStatus = readOpenclawUpdateStatus({ refresh: true }); + latestVersion = updateStatus.latestVersion || null; + hasUpdate = !!updateStatus.hasUpdate; + } catch (error) { + console.log( + `[alphaclaw] Could not resolve exact OpenClaw version before restart: ${error.message || "unknown error"}`, + ); } + + if (!hasUpdate && latestVersion && latestVersion === previousVersion) { + return { + status: 200, + body: { + ok: true, + previousVersion, + currentVersion: previousVersion, + latestVersion, + hasUpdate: false, + restarted: false, + restarting: false, + updated: false, + }, + }; + } + + const targetVersion = latestVersion || "latest"; + const spec = buildOpenclawInstallSpec(targetVersion); + const markerPath = path.join(kRootDir, ".openclaw-update-pending"); + fs.writeFileSync( + markerPath, + JSON.stringify({ + from: previousVersion, + to: targetVersion, + spec, + ts: Date.now(), + }), + ); + console.log( + `[alphaclaw] OpenClaw update marker written to ${markerPath} for ${spec}`, + ); + kOpenclawVersionCache = { value: previousVersion, fetchedAt: 0 }; + kOpenclawUpdateStatusCache = { + latestVersion, + hasUpdate, + fetchedAt: 0, + }; return { status: 200, body: { ok: true, previousVersion, - currentVersion, + currentVersion: previousVersion, + targetVersion: targetVersion === "latest" ? null : targetVersion, latestVersion, - hasUpdate, - restarted, - updated: previousVersion !== currentVersion, + hasUpdate: true, + restarted: false, + restarting: true, + updated: previousVersion !== targetVersion, }, }; } catch (err) { diff --git a/lib/server/pending-alphaclaw-update.js b/lib/server/pending-alphaclaw-update.js new file mode 100644 index 00000000..1f2339ad --- /dev/null +++ b/lib/server/pending-alphaclaw-update.js @@ -0,0 +1,71 @@ +const buildPendingAlphaclawInstallSpec = (marker = {}) => { + const explicitSpec = String(marker?.spec || "").trim(); + if (explicitSpec) { + return explicitSpec; + } + const targetVersion = String(marker?.to || "").trim() || "latest"; + return `@chrysb/alphaclaw@${targetVersion}`; +}; + +const shellQuote = (value) => + `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; + +const applyPendingAlphaclawUpdate = ({ + execSyncImpl, + fsModule, + installDir, + logger = console, + markerPath, +}) => { + if (!fsModule.existsSync(markerPath)) { + return { + attempted: false, + installed: false, + spec: "", + }; + } + + let marker = {}; + try { + marker = JSON.parse(fsModule.readFileSync(markerPath, "utf8")); + } catch { + marker = {}; + } + + const spec = buildPendingAlphaclawInstallSpec(marker); + logger.log(`[alphaclaw] Pending update detected, installing ${spec}...`); + + try { + execSyncImpl( + `npm install ${shellQuote(spec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: installDir, + stdio: "inherit", + timeout: 180000, + }, + ); + fsModule.unlinkSync(markerPath); + logger.log("[alphaclaw] Update applied successfully"); + return { + attempted: true, + installed: true, + spec, + }; + } catch (error) { + logger.log(`[alphaclaw] Update install failed: ${error.message}`); + try { + fsModule.unlinkSync(markerPath); + } catch {} + return { + attempted: true, + installed: false, + spec, + error, + }; + } +}; + +module.exports = { + applyPendingAlphaclawUpdate, + buildPendingAlphaclawInstallSpec, +}; diff --git a/lib/server/pending-openclaw-update.js b/lib/server/pending-openclaw-update.js new file mode 100644 index 00000000..52e1f181 --- /dev/null +++ b/lib/server/pending-openclaw-update.js @@ -0,0 +1,71 @@ +const buildPendingOpenclawInstallSpec = (marker = {}) => { + const explicitSpec = String(marker?.spec || "").trim(); + if (explicitSpec) { + return explicitSpec; + } + const targetVersion = String(marker?.to || "").trim() || "latest"; + return `openclaw@${targetVersion}`; +}; + +const shellQuote = (value) => + `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; + +const applyPendingOpenclawUpdate = ({ + execSyncImpl, + fsModule, + installDir, + logger = console, + markerPath, +}) => { + if (!fsModule.existsSync(markerPath)) { + return { + attempted: false, + installed: false, + spec: "", + }; + } + + let marker = {}; + try { + marker = JSON.parse(fsModule.readFileSync(markerPath, "utf8")); + } catch { + marker = {}; + } + + const spec = buildPendingOpenclawInstallSpec(marker); + logger.log(`[alphaclaw] Pending OpenClaw update detected, installing ${spec}...`); + + try { + execSyncImpl( + `npm install ${shellQuote(spec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: installDir, + stdio: "inherit", + timeout: 180000, + }, + ); + fsModule.unlinkSync(markerPath); + logger.log("[alphaclaw] OpenClaw update applied successfully"); + return { + attempted: true, + installed: true, + spec, + }; + } catch (error) { + logger.log(`[alphaclaw] OpenClaw update install failed: ${error.message}`); + try { + fsModule.unlinkSync(markerPath); + } catch {} + return { + attempted: true, + installed: false, + spec, + error, + }; + } +}; + +module.exports = { + applyPendingOpenclawUpdate, + buildPendingOpenclawInstallSpec, +}; diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index a1345626..b1cdbbf5 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -588,7 +588,12 @@ const registerSystemRoutes = ({ console.log( `[alphaclaw] /api/openclaw/update result: status=${result.status} ok=${result.body?.ok === true}`, ); - res.status(result.status).json(result.body); + if (result.status === 200 && result.body?.ok && result.body?.restarting) { + res.json(result.body); + setTimeout(() => alphaclawVersionService.restartProcess(), 1000); + } else { + res.status(result.status).json(result.body); + } }); app.get("/api/alphaclaw/version", async (req, res) => { diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 98627097..14f1bbf2 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -24,6 +24,10 @@ describe("frontend/api", () => { global.window = { location: { href: "http://localhost/" } }; }); + afterEach(() => { + vi.useRealTimers(); + }); + it("fetchStatus returns parsed JSON on success", async () => { const payload = { gateway: "running" }; global.fetch.mockResolvedValue(mockJsonResponse(200, payload)); @@ -47,6 +51,51 @@ describe("frontend/api", () => { expect(window.location.href).toBe("/setup"); }); + it("waitForAlphaclawRestart resolves once AlphaClaw responds again", async () => { + vi.useFakeTimers(); + global.fetch + .mockResolvedValueOnce({ status: 503 }) + .mockResolvedValueOnce({ status: 200 }); + const api = await loadApiModule(); + + const promise = api.waitForAlphaclawRestart({ + initialDelayMs: 10, + intervalMs: 20, + timeoutMs: 100, + }); + + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toEqual({ ok: true }); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + "/api/auth/status", + expect.objectContaining({ + cache: "no-store", + credentials: "same-origin", + headers: expect.any(Headers), + }), + ); + }); + + it("waitForAlphaclawRestart times out when AlphaClaw does not come back", async () => { + vi.useFakeTimers(); + global.fetch.mockRejectedValue(new Error("offline")); + const api = await loadApiModule(); + + const promise = api.waitForAlphaclawRestart({ + initialDelayMs: 0, + intervalMs: 5, + timeoutMs: 15, + }); + const assertion = expect(promise).rejects.toThrow( + "AlphaClaw restart is taking longer than expected", + ); + + await vi.runAllTimersAsync(); + await assertion; + }); + it("runOnboard sends vars and modelKey payload", async () => { global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true })); const api = await loadApiModule(); diff --git a/tests/frontend/update-modal-helpers.test.js b/tests/frontend/update-modal-helpers.test.js new file mode 100644 index 00000000..571d863e --- /dev/null +++ b/tests/frontend/update-modal-helpers.test.js @@ -0,0 +1,35 @@ +const loadUpdateModalHelpers = async () => + import("../../lib/public/js/components/update-modal-helpers.js"); + +describe("frontend/update-modal-helpers", () => { + it("closes the update modal after a successful update", async () => { + const { createUpdateModalSubmitHandler } = await loadUpdateModalHelpers(); + const onClose = vi.fn(); + const result = { ok: true, restarting: true }; + const onUpdate = vi.fn().mockResolvedValue(result); + + const handler = createUpdateModalSubmitHandler({ + onClose, + onUpdate, + }); + + await expect(handler()).resolves.toEqual(result); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("keeps the update modal open when the update fails", async () => { + const { createUpdateModalSubmitHandler } = await loadUpdateModalHelpers(); + const onClose = vi.fn(); + const result = { ok: false, error: "nope" }; + const onUpdate = vi.fn().mockResolvedValue(result); + + const handler = createUpdateModalSubmitHandler({ + onClose, + onUpdate, + }); + + await expect(handler()).resolves.toEqual(result); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/server/alphaclaw-version.test.js b/tests/server/alphaclaw-version.test.js index 5a789476..f17522f0 100644 --- a/tests/server/alphaclaw-version.test.js +++ b/tests/server/alphaclaw-version.test.js @@ -1,17 +1,10 @@ -const childProcess = require("child_process"); const fs = require("fs"); -const os = require("os"); const path = require("path"); const https = require("https"); const { EventEmitter } = require("events"); -const { - kNpmPackageRoot, - kOpenclawUpdateCopyTimeoutMs, - kRootDir, -} = require("../../lib/server/constants"); +const { kNpmPackageRoot, kRootDir } = require("../../lib/server/constants"); const modulePath = require.resolve("../../lib/server/alphaclaw-version"); -const originalExec = childProcess.exec; const originalHttpsGet = https.get; const createMockHttpsGet = (responseJson) => { @@ -29,8 +22,26 @@ const createMockHttpsGet = (responseJson) => { }); }; -const loadVersionModule = ({ execMock, httpsGetMock } = {}) => { - if (execMock) childProcess.exec = execMock; +const createDeferredHttpsGet = (responseJson) => { + const pending = []; + const httpsGetMock = vi.fn((url, opts, callback) => { + const res = new EventEmitter(); + res.statusCode = 200; + pending.push(() => { + callback(res); + process.nextTick(() => { + res.emit("data", JSON.stringify(responseJson)); + res.emit("end"); + }); + }); + const req = new EventEmitter(); + req.on = vi.fn().mockReturnThis(); + return req; + }); + return { httpsGetMock, pending }; +}; + +const loadVersionModule = ({ httpsGetMock } = {}) => { if (httpsGetMock) https.get = httpsGetMock; delete require.cache[modulePath]; return require(modulePath); @@ -38,7 +49,6 @@ const loadVersionModule = ({ execMock, httpsGetMock } = {}) => { describe("server/alphaclaw-version", () => { afterEach(() => { - childProcess.exec = originalExec; https.get = originalHttpsGet; delete require.cache[modulePath]; }); @@ -77,11 +87,12 @@ describe("server/alphaclaw-version", () => { }); it("returns 409 while another update is in progress", async () => { - const callbacks = []; - const execMock = vi.fn().mockImplementation((cmd, opts, callback) => { - callbacks.push(callback); + const { httpsGetMock, pending } = createDeferredHttpsGet({ + "dist-tags": { latest: "99.0.0" }, + }); + const { createAlphaclawVersionService } = loadVersionModule({ + httpsGetMock, }); - const { createAlphaclawVersionService } = loadVersionModule({ execMock }); const service = createAlphaclawVersionService(); const firstPromise = service.updateAlphaclaw(); @@ -94,19 +105,17 @@ describe("server/alphaclaw-version", () => { error: "AlphaClaw update already in progress", }); - callbacks[0](null, "installed", ""); - await new Promise((resolve) => { - setImmediate(resolve); - }); - callbacks[1](null, "", ""); + pending[0](); await firstPromise; }); - it("returns successful update result with restarting flag", async () => { - const execMock = vi.fn().mockImplementation((cmd, opts, callback) => { - callback(null, "added 1 package", ""); + it("returns successful update result with restarting flag and exact target version", async () => { + const httpsGetMock = createMockHttpsGet({ + "dist-tags": { latest: "99.0.0" }, + }); + const { createAlphaclawVersionService } = loadVersionModule({ + httpsGetMock, }); - const { createAlphaclawVersionService } = loadVersionModule({ execMock }); const service = createAlphaclawVersionService(); const result = await service.updateAlphaclaw(); @@ -115,53 +124,74 @@ describe("server/alphaclaw-version", () => { expect(result.body.ok).toBe(true); expect(result.body.restarting).toBe(true); expect(result.body.previousVersion).toBeTruthy(); - expect(execMock).toHaveBeenCalledTimes(2); - expect(execMock).toHaveBeenNthCalledWith( - 1, - "npm install --omit=dev --prefer-online --package-lock=false", - expect.objectContaining({ - cwd: expect.stringContaining(path.join(os.tmpdir(), "alphaclaw-update-")), - env: expect.objectContaining({ - npm_config_update_notifier: "false", - npm_config_fund: "false", - npm_config_audit: "false", - }), - timeout: 180000, - }), - expect.any(Function), - ); - expect(execMock).toHaveBeenNthCalledWith( - 2, - expect.stringMatching(/^cp -af /), - expect.objectContaining({ timeout: kOpenclawUpdateCopyTimeoutMs }), - expect.any(Function), - ); + expect(result.body.targetVersion).toBe("99.0.0"); + }); + + it("falls back to latest marker when the registry lookup fails", async () => { + const httpsGetMock = vi.fn((url, opts, callback) => { + const req = new EventEmitter(); + req.on = vi.fn().mockImplementation((event, handler) => { + if (event === "error") { + process.nextTick(() => handler(new Error("network timeout"))); + } + return req; + }); + return req; + }); + const writeSpy = vi.spyOn(fs, "writeFileSync"); + const { createAlphaclawVersionService } = loadVersionModule({ + httpsGetMock, + }); + const service = createAlphaclawVersionService(); + + const result = await service.updateAlphaclaw(); + + expect(result.status).toBe(200); + expect(result.body.ok).toBe(true); + expect(result.body.targetVersion).toBe(null); + const markerPath = path.join(kRootDir, ".alphaclaw-update-pending"); + const markerCall = writeSpy.mock.calls.find((call) => call[0] === markerPath); + expect(markerCall).toBeTruthy(); + expect(JSON.parse(markerCall[1])).toMatchObject({ + spec: "@chrysb/alphaclaw@latest", + to: "latest", + }); + + writeSpy.mockRestore(); }); - it("returns 500 when npm install fails", async () => { - const execMock = vi.fn().mockImplementation((cmd, opts, callback) => { - callback( - new Error("npm ERR! network timeout"), - "", - "npm ERR! network timeout", - ); + it("returns 500 when it cannot write the pending update marker", async () => { + const httpsGetMock = createMockHttpsGet({ + "dist-tags": { latest: "99.0.0" }, + }); + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((targetPath) => { + if (targetPath === path.join(kRootDir, ".alphaclaw-update-pending")) { + throw new Error("disk full"); + } + return undefined; + }); + const { createAlphaclawVersionService } = loadVersionModule({ + httpsGetMock, }); - const { createAlphaclawVersionService } = loadVersionModule({ execMock }); const service = createAlphaclawVersionService(); const result = await service.updateAlphaclaw(); expect(result.status).toBe(500); expect(result.body.ok).toBe(false); - expect(result.body.error).toContain("npm ERR!"); + expect(result.body.error).toContain("disk full"); + + writeSpy.mockRestore(); }); it("writes update marker to kRootDir on successful update", async () => { - const execMock = vi.fn().mockImplementation((cmd, opts, callback) => { - callback(null, "added 1 package", ""); + const httpsGetMock = createMockHttpsGet({ + "dist-tags": { latest: "99.0.0" }, }); const writeSpy = vi.spyOn(fs, "writeFileSync"); - const { createAlphaclawVersionService } = loadVersionModule({ execMock }); + const { createAlphaclawVersionService } = loadVersionModule({ + httpsGetMock, + }); const service = createAlphaclawVersionService(); const result = await service.updateAlphaclaw(); @@ -174,6 +204,8 @@ describe("server/alphaclaw-version", () => { expect(markerCall).toBeTruthy(); const markerData = JSON.parse(markerCall[1]); expect(markerData).toHaveProperty("from"); + expect(markerData).toHaveProperty("to", "99.0.0"); + expect(markerData).toHaveProperty("spec", "@chrysb/alphaclaw@99.0.0"); expect(markerData).toHaveProperty("ts"); writeSpy.mockRestore(); diff --git a/tests/server/openclaw-version.test.js b/tests/server/openclaw-version.test.js index f1f828cf..d02059e3 100644 --- a/tests/server/openclaw-version.test.js +++ b/tests/server/openclaw-version.test.js @@ -1,25 +1,20 @@ +const fs = require("fs"); +const path = require("path"); const childProcess = require("child_process"); -const { - kNpmPackageRoot, - kOpenclawUpdateCopyTimeoutMs, -} = require("../../lib/server/constants"); +const { kRootDir } = require("../../lib/server/constants"); const modulePath = require.resolve("../../lib/server/openclaw-version"); -const originalExec = childProcess.exec; const originalExecSync = childProcess.execSync; -const loadVersionModule = ({ execMock, execSyncMock }) => { - childProcess.exec = execMock; +const loadVersionModule = ({ execSyncMock }) => { childProcess.execSync = execSyncMock; delete require.cache[modulePath]; return require(modulePath); }; const createService = ({ isOnboarded = false } = {}) => { - const execMock = vi.fn(); const execSyncMock = vi.fn(); const { createOpenclawVersionService } = loadVersionModule({ - execMock, execSyncMock, }); const gatewayEnv = vi.fn(() => ({ OPENCLAW_GATEWAY_TOKEN: "token" })); @@ -29,12 +24,11 @@ const createService = ({ isOnboarded = false } = {}) => { restartGateway, isOnboarded: () => isOnboarded, }); - return { service, gatewayEnv, restartGateway, execMock, execSyncMock }; + return { service, gatewayEnv, restartGateway, execSyncMock }; }; describe("server/openclaw-version", () => { afterEach(() => { - childProcess.exec = originalExec; childProcess.execSync = originalExecSync; delete require.cache[modulePath]; }); @@ -111,21 +105,53 @@ describe("server/openclaw-version", () => { expect(status.error).toContain("status check failed"); }); - it("updates openclaw and restarts gateway when onboarded", async () => { - const { service, restartGateway, execMock, execSyncMock } = createService({ - isOnboarded: true, + it("queues an exact openclaw update and requests restart", async () => { + const { service, restartGateway, execSyncMock } = createService(); + execSyncMock.mockReturnValueOnce("openclaw 1.0.0").mockReturnValueOnce( + JSON.stringify({ + availability: { available: true, latestVersion: "1.1.0" }, + }), + ); + const writeSpy = vi.spyOn(fs, "writeFileSync"); + + const result = await service.updateOpenclaw(); + + expect(result.status).toBe(200); + expect(result.body).toEqual( + expect.objectContaining({ + ok: true, + previousVersion: "1.0.0", + currentVersion: "1.0.0", + targetVersion: "1.1.0", + latestVersion: "1.1.0", + hasUpdate: true, + restarted: false, + restarting: true, + updated: true, + }), + ); + const markerPath = path.join(kRootDir, ".openclaw-update-pending"); + const markerCall = writeSpy.mock.calls.find((call) => call[0] === markerPath); + expect(markerCall).toBeTruthy(); + expect(JSON.parse(markerCall[1])).toMatchObject({ + from: "1.0.0", + to: "1.1.0", + spec: "openclaw@1.1.0", }); - execSyncMock - .mockReturnValueOnce("openclaw 1.0.0") - .mockReturnValueOnce("openclaw 1.1.0") - .mockReturnValueOnce( - JSON.stringify({ - availability: { available: false, latestVersion: "1.1.0" }, - }), - ); - execMock.mockImplementation((cmd, opts, callback) => { - callback(null, "installed", ""); + expect(restartGateway).not.toHaveBeenCalled(); + + writeSpy.mockRestore(); + }); + + it("returns without restart when openclaw is already current", async () => { + const { service, restartGateway, execSyncMock } = createService({ + isOnboarded: true, }); + execSyncMock.mockReturnValueOnce("openclaw 1.1.0").mockReturnValueOnce( + JSON.stringify({ + availability: { available: false, latestVersion: "1.1.0" }, + }), + ); const result = await service.updateOpenclaw(); @@ -133,72 +159,74 @@ describe("server/openclaw-version", () => { expect(result.body).toEqual( expect.objectContaining({ ok: true, - previousVersion: "1.0.0", + previousVersion: "1.1.0", currentVersion: "1.1.0", latestVersion: "1.1.0", hasUpdate: false, - restarted: true, - updated: true, + restarted: false, + restarting: false, + updated: false, }), ); - expect(execMock).toHaveBeenCalledTimes(2); - expect(execMock).toHaveBeenNthCalledWith( - 1, - "npm install --omit=dev --prefer-online --package-lock=false", + expect(restartGateway).not.toHaveBeenCalled(); + }); + + it("falls back to latest marker when version resolution fails", async () => { + const { service, execSyncMock } = createService(); + execSyncMock + .mockReturnValueOnce("openclaw 1.0.0") + .mockImplementationOnce(() => { + throw new Error("status check failed"); + }); + const writeSpy = vi.spyOn(fs, "writeFileSync"); + + const result = await service.updateOpenclaw(); + + expect(result.status).toBe(200); + expect(result.body).toEqual( expect.objectContaining({ - env: expect.objectContaining({ - npm_config_update_notifier: "false", - npm_config_fund: "false", - npm_config_audit: "false", - }), - timeout: 180000, + ok: true, + previousVersion: "1.0.0", + currentVersion: "1.0.0", + targetVersion: null, + latestVersion: null, + hasUpdate: true, + restarting: true, }), - expect.any(Function), ); - expect(execMock).toHaveBeenNthCalledWith( - 2, - expect.stringMatching(/^cp -af /), - expect.objectContaining({ timeout: kOpenclawUpdateCopyTimeoutMs }), - expect.any(Function), - ); - expect(restartGateway).toHaveBeenCalledTimes(1); + const markerPath = path.join(kRootDir, ".openclaw-update-pending"); + const markerCall = writeSpy.mock.calls.find((call) => call[0] === markerPath); + expect(markerCall).toBeTruthy(); + expect(JSON.parse(markerCall[1])).toMatchObject({ + from: "1.0.0", + to: "latest", + spec: "openclaw@latest", + }); + + writeSpy.mockRestore(); }); - it("returns 409 while another update is in progress", async () => { - const { service, execMock, execSyncMock } = createService(); - execSyncMock.mockImplementation((command) => { - if (command === "openclaw --version") { - return "openclaw 1.0.0"; - } - if (command === "openclaw update status --json") { - return JSON.stringify({ - availability: { available: true, latestVersion: "1.1.0" }, - }); + it("returns 500 when it cannot write the pending update marker", async () => { + const { service, execSyncMock } = createService(); + execSyncMock.mockReturnValueOnce("openclaw 1.0.0").mockReturnValueOnce( + JSON.stringify({ + availability: { available: true, latestVersion: "1.1.0" }, + }), + ); + const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((targetPath) => { + if (targetPath === path.join(kRootDir, ".openclaw-update-pending")) { + throw new Error("disk full"); } - throw new Error(`Unexpected command: ${command}`); - }); - const callbacks = []; - execMock.mockImplementation((cmd, opts, callback) => { - callbacks.push(callback); + return undefined; }); - const firstUpdatePromise = service.updateOpenclaw(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - const secondUpdate = await service.updateOpenclaw(); + const result = await service.updateOpenclaw(); - expect(secondUpdate.status).toBe(409); - expect(secondUpdate.body).toEqual({ - ok: false, - error: "OpenClaw update already in progress", - }); + expect(result.status).toBe(500); + expect(result.body.ok).toBe(false); + expect(result.body.error).toContain("disk full"); - callbacks[0](null, "installed", ""); - await new Promise((resolve) => { - setImmediate(resolve); - }); - callbacks[1](null, "", ""); - await firstUpdatePromise; + writeSpy.mockRestore(); }); + }); diff --git a/tests/server/pending-alphaclaw-update.test.js b/tests/server/pending-alphaclaw-update.test.js new file mode 100644 index 00000000..390a1a90 --- /dev/null +++ b/tests/server/pending-alphaclaw-update.test.js @@ -0,0 +1,102 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + applyPendingAlphaclawUpdate, + buildPendingAlphaclawInstallSpec, +} = require("../../lib/server/pending-alphaclaw-update"); + +describe("server/pending-alphaclaw-update", () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-pending-")); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + it("builds the install spec from an explicit marker spec", () => { + expect( + buildPendingAlphaclawInstallSpec({ + spec: "@chrysb/alphaclaw@0.8.6", + to: "0.8.5", + }), + ).toBe("@chrysb/alphaclaw@0.8.6"); + }); + + it("falls back to the marker version when spec is not present", () => { + expect(buildPendingAlphaclawInstallSpec({ to: "0.8.6" })).toBe( + "@chrysb/alphaclaw@0.8.6", + ); + }); + + it("falls back to latest for legacy or invalid markers", () => { + expect(buildPendingAlphaclawInstallSpec({})).toBe( + "@chrysb/alphaclaw@latest", + ); + }); + + it("installs the pending update with a real npm install command and clears the marker", () => { + const markerPath = path.join(tmpDir, ".alphaclaw-update-pending"); + fs.writeFileSync( + markerPath, + JSON.stringify({ + from: "0.8.5", + to: "0.8.6", + spec: "@chrysb/alphaclaw@0.8.6", + ts: Date.now(), + }), + ); + const execSyncImpl = vi.fn(); + + const result = applyPendingAlphaclawUpdate({ + execSyncImpl, + fsModule: fs, + installDir: tmpDir, + logger: { log: vi.fn() }, + markerPath, + }); + + expect(result).toEqual({ + attempted: true, + installed: true, + spec: "@chrysb/alphaclaw@0.8.6", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + "npm install '@chrysb/alphaclaw@0.8.6' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + { + cwd: tmpDir, + stdio: "inherit", + timeout: 180000, + }, + ); + expect(fs.existsSync(markerPath)).toBe(false); + }); + + it("removes the marker and reports failure when npm install throws", () => { + const markerPath = path.join(tmpDir, ".alphaclaw-update-pending"); + fs.writeFileSync(markerPath, "{not-json"); + const execSyncImpl = vi.fn(() => { + throw new Error("boom"); + }); + + const result = applyPendingAlphaclawUpdate({ + execSyncImpl, + fsModule: fs, + installDir: tmpDir, + logger: { log: vi.fn() }, + markerPath, + }); + + expect(result.attempted).toBe(true); + expect(result.installed).toBe(false); + expect(result.spec).toBe("@chrysb/alphaclaw@latest"); + expect(result.error).toBeInstanceOf(Error); + expect(fs.existsSync(markerPath)).toBe(false); + }); +}); diff --git a/tests/server/pending-openclaw-update.test.js b/tests/server/pending-openclaw-update.test.js new file mode 100644 index 00000000..846ba79b --- /dev/null +++ b/tests/server/pending-openclaw-update.test.js @@ -0,0 +1,100 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + applyPendingOpenclawUpdate, + buildPendingOpenclawInstallSpec, +} = require("../../lib/server/pending-openclaw-update"); + +describe("server/pending-openclaw-update", () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pending-")); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + it("builds the install spec from an explicit marker spec", () => { + expect( + buildPendingOpenclawInstallSpec({ + spec: "openclaw@1.1.0", + to: "1.0.9", + }), + ).toBe("openclaw@1.1.0"); + }); + + it("falls back to the marker version when spec is not present", () => { + expect(buildPendingOpenclawInstallSpec({ to: "1.1.0" })).toBe( + "openclaw@1.1.0", + ); + }); + + it("falls back to latest for legacy or invalid markers", () => { + expect(buildPendingOpenclawInstallSpec({})).toBe("openclaw@latest"); + }); + + it("installs the pending update with a real npm install command and clears the marker", () => { + const markerPath = path.join(tmpDir, ".openclaw-update-pending"); + fs.writeFileSync( + markerPath, + JSON.stringify({ + from: "1.0.9", + to: "1.1.0", + spec: "openclaw@1.1.0", + ts: Date.now(), + }), + ); + const execSyncImpl = vi.fn(); + + const result = applyPendingOpenclawUpdate({ + execSyncImpl, + fsModule: fs, + installDir: tmpDir, + logger: { log: vi.fn() }, + markerPath, + }); + + expect(result).toEqual({ + attempted: true, + installed: true, + spec: "openclaw@1.1.0", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + "npm install 'openclaw@1.1.0' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + { + cwd: tmpDir, + stdio: "inherit", + timeout: 180000, + }, + ); + expect(fs.existsSync(markerPath)).toBe(false); + }); + + it("removes the marker and reports failure when npm install throws", () => { + const markerPath = path.join(tmpDir, ".openclaw-update-pending"); + fs.writeFileSync(markerPath, "{not-json"); + const execSyncImpl = vi.fn(() => { + throw new Error("boom"); + }); + + const result = applyPendingOpenclawUpdate({ + execSyncImpl, + fsModule: fs, + installDir: tmpDir, + logger: { log: vi.fn() }, + markerPath, + }); + + expect(result.attempted).toBe(true); + expect(result.installed).toBe(false); + expect(result.spec).toBe("openclaw@latest"); + expect(result.error).toBeInstanceOf(Error); + expect(fs.existsSync(markerPath)).toBe(false); + }); +}); diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index 7cb1853b..ad7c7962 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -99,6 +99,10 @@ const createApp = (deps) => { }; describe("server/routes/system", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("merges known vars and custom vars on GET /api/env", async () => { const deps = createSystemDeps(); deps.readEnvFile.mockReturnValue([ @@ -395,6 +399,68 @@ describe("server/routes/system", () => { expect(res.body.ok).toBe(true); }); + it("returns openclaw version status on GET /api/openclaw/version", async () => { + const deps = createSystemDeps(); + deps.openclawVersionService.getVersionStatus.mockResolvedValue({ + ok: true, + currentVersion: "1.2.3", + latestVersion: "1.3.0", + hasUpdate: true, + }); + const app = createApp(deps); + + const res = await request(app).get("/api/openclaw/version"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + currentVersion: "1.2.3", + latestVersion: "1.3.0", + hasUpdate: true, + }); + expect(deps.openclawVersionService.getVersionStatus).toHaveBeenCalledWith(false); + }); + + it("passes refresh flag to openclaw version service", async () => { + const deps = createSystemDeps(); + const app = createApp(deps); + + await request(app).get("/api/openclaw/version?refresh=1"); + + expect(deps.openclawVersionService.getVersionStatus).toHaveBeenCalledWith(true); + }); + + it("returns update result and schedules restart on POST /api/openclaw/update", async () => { + vi.useFakeTimers(); + const deps = createSystemDeps(); + deps.openclawVersionService.updateOpenclaw.mockResolvedValue({ + status: 200, + body: { + ok: true, + previousVersion: "1.2.3", + targetVersion: "1.3.0", + restarting: true, + }, + }); + const app = createApp(deps); + + const res = await request(app).post("/api/openclaw/update"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ok: true, + previousVersion: "1.2.3", + targetVersion: "1.3.0", + restarting: true, + }); + expect(deps.openclawVersionService.updateOpenclaw).toHaveBeenCalledTimes(1); + expect(deps.alphaclawVersionService.restartProcess).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + + expect(deps.alphaclawVersionService.restartProcess).toHaveBeenCalledTimes(1); + }); + it("returns alphaclaw version status on GET /api/alphaclaw/version", async () => { const deps = createSystemDeps(); const app = createApp(deps); @@ -421,6 +487,7 @@ describe("server/routes/system", () => { }); it("returns update result and schedules restart on POST /api/alphaclaw/update", async () => { + vi.useFakeTimers(); const deps = createSystemDeps(); const app = createApp(deps); @@ -433,6 +500,10 @@ describe("server/routes/system", () => { restarting: true, }); expect(deps.alphaclawVersionService.updateAlphaclaw).toHaveBeenCalledTimes(1); + + await vi.runAllTimersAsync(); + + expect(deps.alphaclawVersionService.restartProcess).toHaveBeenCalledTimes(1); }); it("returns error status when alphaclaw update fails", async () => { From dd78cff7d9feecfc35907eaaf8a6be9836485441 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 22:36:29 -0700 Subject: [PATCH 02/26] Prevent duplicate import apply during onboarding --- .../onboarding/welcome-form-step.js | 33 ++++++++++-- lib/public/js/components/welcome/index.js | 1 + .../js/components/welcome/use-welcome.js | 54 ++++++++++++++++++- tests/frontend/use-welcome.test.js | 38 +++++++++++++ 4 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 tests/frontend/use-welcome.test.js diff --git a/lib/public/js/components/onboarding/welcome-form-step.js b/lib/public/js/components/onboarding/welcome-form-step.js index 94e49e55..f0e2b820 100644 --- a/lib/public/js/components/onboarding/welcome-form-step.js +++ b/lib/public/js/components/onboarding/welcome-form-step.js @@ -50,6 +50,7 @@ export const WelcomeFormStep = ({ error, step, totalGroups, + importApplied, goBack, goNext, loading, @@ -88,18 +89,28 @@ export const WelcomeFormStep = ({ }); }, [activeGroup.id]); - const renderStandardField = (field) => html` + const renderStandardField = (field) => { + const isLockedImportSourceField = + activeGroup.id === "github" && + githubFlow === kGithubFlowImport && + importApplied && + field.key === "_GITHUB_SOURCE_REPO"; + + return html`
<${SecretInput} key=${field.key} value=${vals[field.key] || ""} onInput=${(e) => setValue(field.key, e.target.value)} + disabled=${isLockedImportSourceField} placeholder=${activeGroup.id === "github" && field.key === "GITHUB_TOKEN" ? githubTokenPlaceholder : field.placeholder || ""} isSecret=${!field.isText} - inputClass="flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono" + inputClass=${`flex-1 bg-field border border-border rounded-lg px-3 py-2 text-sm text-body outline-none focus:border-fg-muted font-mono ${ + isLockedImportSourceField ? "opacity-60 cursor-not-allowed" : "" + }`} />

${activeGroup.id === "github" && @@ -110,7 +121,9 @@ export const WelcomeFormStep = ({ ? "Enter the owner/repo of an existing empty repository" : "A new private repo will be created for you" : activeGroup.id === "github" && field.key === "_GITHUB_SOURCE_REPO" - ? "The repo to import from" + ? importApplied + ? "This source repo is already imported locally. You can still change the target repo below." + : "The repo to import from" : activeGroup.id === "github" && field.key === "GITHUB_TOKEN" ? githubFlow === kGithubFlowImport ? freshRepoMode === kGithubTargetRepoModeCreate @@ -139,7 +152,8 @@ export const WelcomeFormStep = ({ : field.hint}

- `; + `; + }; const fieldLookup = new Map((activeGroup.fields || []).map((field) => [field.key, field])); const toggleChannelSection = (channelId) => setExpandedChannels((current) => { @@ -327,6 +341,17 @@ export const WelcomeFormStep = ({ ${activeGroup.id === "github" && html`
+ ${githubFlow === kGithubFlowImport && importApplied + ? html` +
+ The import source is already applied locally. You can still + change the target repo before finishing setup, but we will not + re-import the source repo a second time. +
+ ` + : null} ${githubFlow === kGithubFlowFresh ? html`
diff --git a/lib/public/js/components/welcome/index.js b/lib/public/js/components/welcome/index.js index 026f4348..d32131ed 100644 --- a/lib/public/js/components/welcome/index.js +++ b/lib/public/js/components/welcome/index.js @@ -102,6 +102,7 @@ export const Welcome = ({ onComplete, acVersion }) => { error=${state.formError} step=${state.step} totalGroups=${kWelcomeGroups.length} + importApplied=${state.importApplied} goBack=${actions.goBack} goNext=${actions.goNext} loading=${state.loading} diff --git a/lib/public/js/components/welcome/use-welcome.js b/lib/public/js/components/welcome/use-welcome.js index d071325b..62359bf5 100644 --- a/lib/public/js/components/welcome/use-welcome.js +++ b/lib/public/js/components/welcome/use-welcome.js @@ -23,6 +23,7 @@ import { kWelcomeGroups, getWelcomeGroupError, findFirstInvalidWelcomeGroup, + normalizeGithubRepoInput, isValidGithubRepoInput, kGithubFlowFresh, kGithubFlowImport, @@ -48,6 +49,8 @@ export const kImportStepId = "import"; export const kSecretReviewStepId = "secret-review"; export const kPlaceholderReviewStepId = "placeholder-review"; const kImportSubstepKey = "_IMPORT_SUBSTEP"; +const kImportAppliedKey = "_IMPORT_APPLIED"; +const kImportedSourceRepoKey = "_IMPORTED_SOURCE_REPO"; const kImportPlaceholderReviewKey = "_IMPORT_PLACEHOLDER_REVIEW"; const kImportPlaceholderSkipConfirmedKey = "_IMPORT_PLACEHOLDER_SKIP_CONFIRMED"; @@ -81,6 +84,30 @@ const normalizePlaceholderReview = (review) => { }; }; +const normalizeTrackedRepo = (repoInput) => + normalizeGithubRepoInput(repoInput).toLowerCase(); + +export const getImportReuseState = ({ + githubFlow, + importApplied, + sourceRepo, + importedSourceRepo, +}) => { + const sourceImportAlreadyApplied = + githubFlow === kGithubFlowImport && !!importApplied; + const normalizedSourceRepo = normalizeTrackedRepo(sourceRepo); + const normalizedImportedSourceRepo = normalizeTrackedRepo(importedSourceRepo); + const sourceRepoChangedAfterImport = + sourceImportAlreadyApplied && + !!normalizedImportedSourceRepo && + normalizedSourceRepo !== normalizedImportedSourceRepo; + + return { + sourceImportAlreadyApplied, + sourceRepoChangedAfterImport, + }; +}; + export const useWelcome = ({ onComplete }) => { const kSetupStepIndex = kWelcomeGroups.length; const kPairingStepIndex = kSetupStepIndex + 1; @@ -203,6 +230,7 @@ export const useWelcome = ({ onComplete }) => { const placeholderReview = normalizePlaceholderReview( vals[kImportPlaceholderReviewKey], ); + const importApplied = !!vals[kImportAppliedKey]; const featuredModels = getFeaturedModels(models); const baseModelOptions = showAllModels ? models @@ -385,6 +413,19 @@ export const useWelcome = ({ onComplete }) => { ? kGithubTargetRepoModeCreate : normalizedVals._GITHUB_TARGET_REPO_MODE || kGithubTargetRepoModeCreate; + const { sourceImportAlreadyApplied, sourceRepoChangedAfterImport } = + getImportReuseState({ + githubFlow, + importApplied: normalizedVals[kImportAppliedKey], + sourceRepo: normalizedVals._GITHUB_SOURCE_REPO, + importedSourceRepo: normalizedVals[kImportedSourceRepoKey], + }); + if (sourceRepoChangedAfterImport) { + setFormError( + "The source repo has already been imported into this setup. You can still change the target repo, but changing the source repo requires restarting onboarding.", + ); + return; + } const targetVerifyMode = targetRepoMode === kGithubTargetRepoModeExistingEmpty ? kRepoModeExisting @@ -394,9 +435,11 @@ export const useWelcome = ({ onComplete }) => { ? normalizedVals._GITHUB_SOURCE_REPO : normalizedVals.GITHUB_WORKSPACE_REPO; setGithubStepLoading(true); - clearPlaceholderReview(); + if (!sourceImportAlreadyApplied) { + clearPlaceholderReview(); + } try { - if (githubFlow === kGithubFlowImport) { + if (githubFlow === kGithubFlowImport && !sourceImportAlreadyApplied) { const sourceResult = await verifyGithubOnboardingRepo( sourceRepo, normalizedVals.GITHUB_TOKEN, @@ -518,10 +561,16 @@ export const useWelcome = ({ onComplete }) => { const nextPlaceholderReview = normalizePlaceholderReview( result.placeholderReview, ); + const importedSourceRepo = + vals._GITHUB_FLOW === kGithubFlowImport + ? normalizeGithubRepoInput(vals._GITHUB_SOURCE_REPO) + : ""; setVals((prev) => ({ ...prev, ...approvedImportVals, ...(result.preFill || {}), + [kImportAppliedKey]: true, + [kImportedSourceRepoKey]: importedSourceRepo, [kImportPlaceholderReviewKey]: nextPlaceholderReview, [kImportPlaceholderSkipConfirmedKey]: false, })); @@ -607,6 +656,7 @@ export const useWelcome = ({ onComplete }) => { importScanResult, importScanning, importError, + importApplied, selectedProvider, modelOptions, canToggleFullCatalog, diff --git a/tests/frontend/use-welcome.test.js b/tests/frontend/use-welcome.test.js new file mode 100644 index 00000000..5a848805 --- /dev/null +++ b/tests/frontend/use-welcome.test.js @@ -0,0 +1,38 @@ +describe("frontend/use-welcome", () => { + it("reuses an applied import when only the target repo changes", async () => { + const { getImportReuseState, kImportStepId } = await import( + "../../lib/public/js/components/welcome/use-welcome.js" + ); + + expect(kImportStepId).toBe("import"); + expect( + getImportReuseState({ + githubFlow: "import", + importApplied: true, + sourceRepo: "My-Org/Source-Repo", + importedSourceRepo: "my-org/source-repo", + }), + ).toEqual({ + sourceImportAlreadyApplied: true, + sourceRepoChangedAfterImport: false, + }); + }); + + it("flags a changed source repo after the import has already been applied", async () => { + const { getImportReuseState } = await import( + "../../lib/public/js/components/welcome/use-welcome.js" + ); + + expect( + getImportReuseState({ + githubFlow: "import", + importApplied: true, + sourceRepo: "my-org/other-source", + importedSourceRepo: "my-org/source-repo", + }), + ).toEqual({ + sourceImportAlreadyApplied: true, + sourceRepoChangedAfterImport: true, + }); + }); +}); From 353fb449b0eb2bfc0ed7045eb7d8c26242b578ef Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 22:36:47 -0700 Subject: [PATCH 03/26] 0.8.7-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39c0eac2..f0cad08d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.6", + "version": "0.8.7-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.6", + "version": "0.8.7-beta.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5ba0915d..5e91cee4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.6", + "version": "0.8.7-beta.0", "publishConfig": { "access": "public" }, From ed7cfcee84236996dc5d08f9269288a55d000ab2 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 22:57:23 -0700 Subject: [PATCH 04/26] Isolate OpenClaw runtime updates from /app --- bin/alphaclaw.js | 19 +++-- lib/server/openclaw-runtime.js | 72 +++++++++++++++++ lib/server/pending-openclaw-update.js | 8 ++ tests/server/openclaw-runtime.test.js | 82 ++++++++++++++++++++ tests/server/pending-openclaw-update.test.js | 6 ++ 5 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 lib/server/openclaw-runtime.js create mode 100644 tests/server/openclaw-runtime.test.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index d49b6188..e53953ee 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -19,6 +19,10 @@ const { const { applyPendingOpenclawUpdate, } = require("../lib/server/pending-openclaw-update"); +const { + getManagedOpenclawRuntimeDir, + prependManagedOpenclawBinToPath, +} = require("../lib/server/openclaw-runtime"); const kUsageTrackerPluginPath = path.resolve( __dirname, @@ -206,21 +210,22 @@ if (fs.existsSync(pendingUpdateMarker)) { } const pendingOpenclawUpdateMarker = path.join(rootDir, ".openclaw-update-pending"); +const managedOpenclawRuntimeDir = getManagedOpenclawRuntimeDir({ rootDir }); if (fs.existsSync(pendingOpenclawUpdateMarker)) { - const alphaPkgRoot = path.resolve(__dirname, ".."); - const nmIndex = alphaPkgRoot.lastIndexOf( - `${path.sep}node_modules${path.sep}`, - ); - const installDir = - nmIndex >= 0 ? alphaPkgRoot.slice(0, nmIndex) : alphaPkgRoot; applyPendingOpenclawUpdate({ execSyncImpl: execSync, fsModule: fs, - installDir, + installDir: managedOpenclawRuntimeDir, logger: console, markerPath: pendingOpenclawUpdateMarker, }); } +prependManagedOpenclawBinToPath({ + env: process.env, + fsModule: fs, + logger: console, + runtimeDir: managedOpenclawRuntimeDir, +}); // --------------------------------------------------------------------------- // 3. Symlink ~/.openclaw -> /.openclaw diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js new file mode 100644 index 00000000..7c8cfe13 --- /dev/null +++ b/lib/server/openclaw-runtime.js @@ -0,0 +1,72 @@ +const fs = require("fs"); +const path = require("path"); + +const { kRootDir } = require("./constants"); + +const getManagedOpenclawRuntimeDir = ({ rootDir = kRootDir } = {}) => + path.join(rootDir, ".openclaw-runtime"); + +const getManagedOpenclawBinDir = ({ runtimeDir } = {}) => + path.join( + runtimeDir || getManagedOpenclawRuntimeDir(), + "node_modules", + ".bin", + ); + +const getManagedOpenclawBinPath = ({ runtimeDir } = {}) => + path.join(getManagedOpenclawBinDir({ runtimeDir }), "openclaw"); + +const ensureManagedOpenclawRuntimeProject = ({ + fsModule = fs, + runtimeDir, +} = {}) => { + const resolvedRuntimeDir = runtimeDir || getManagedOpenclawRuntimeDir(); + const packageJsonPath = path.join(resolvedRuntimeDir, "package.json"); + fsModule.mkdirSync(resolvedRuntimeDir, { recursive: true }); + if (!fsModule.existsSync(packageJsonPath)) { + fsModule.writeFileSync( + packageJsonPath, + JSON.stringify( + { + name: "alphaclaw-openclaw-runtime", + private: true, + }, + null, + 2, + ), + ); + } + return { + runtimeDir: resolvedRuntimeDir, + packageJsonPath, + }; +}; + +const prependManagedOpenclawBinToPath = ({ + env = process.env, + fsModule = fs, + logger = console, + runtimeDir, +} = {}) => { + const resolvedRuntimeDir = runtimeDir || getManagedOpenclawRuntimeDir(); + const binDir = getManagedOpenclawBinDir({ runtimeDir: resolvedRuntimeDir }); + const binPath = getManagedOpenclawBinPath({ runtimeDir: resolvedRuntimeDir }); + if (!fsModule.existsSync(binPath)) { + return false; + } + const currentEntries = String(env.PATH || "") + .split(path.delimiter) + .filter(Boolean); + const nextEntries = [binDir, ...currentEntries.filter((entry) => entry !== binDir)]; + env.PATH = nextEntries.join(path.delimiter); + logger.log(`[alphaclaw] Using managed OpenClaw runtime from ${resolvedRuntimeDir}`); + return true; +}; + +module.exports = { + ensureManagedOpenclawRuntimeProject, + getManagedOpenclawBinDir, + getManagedOpenclawBinPath, + getManagedOpenclawRuntimeDir, + prependManagedOpenclawBinToPath, +}; diff --git a/lib/server/pending-openclaw-update.js b/lib/server/pending-openclaw-update.js index 52e1f181..8c526166 100644 --- a/lib/server/pending-openclaw-update.js +++ b/lib/server/pending-openclaw-update.js @@ -1,3 +1,7 @@ +const { + ensureManagedOpenclawRuntimeProject, +} = require("./openclaw-runtime"); + const buildPendingOpenclawInstallSpec = (marker = {}) => { const explicitSpec = String(marker?.spec || "").trim(); if (explicitSpec) { @@ -36,6 +40,10 @@ const applyPendingOpenclawUpdate = ({ logger.log(`[alphaclaw] Pending OpenClaw update detected, installing ${spec}...`); try { + ensureManagedOpenclawRuntimeProject({ + fsModule, + runtimeDir: installDir, + }); execSyncImpl( `npm install ${shellQuote(spec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, { diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js new file mode 100644 index 00000000..8227cc09 --- /dev/null +++ b/tests/server/openclaw-runtime.test.js @@ -0,0 +1,82 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + ensureManagedOpenclawRuntimeProject, + getManagedOpenclawBinDir, + getManagedOpenclawBinPath, + getManagedOpenclawRuntimeDir, + prependManagedOpenclawBinToPath, +} = require("../../lib/server/openclaw-runtime"); + +describe("server/openclaw-runtime", () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-")); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + it("builds the managed runtime directory under the AlphaClaw root", () => { + expect(getManagedOpenclawRuntimeDir({ rootDir: tmpDir })).toBe( + path.join(tmpDir, ".openclaw-runtime"), + ); + }); + + it("seeds a minimal runtime package.json when needed", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + + const result = ensureManagedOpenclawRuntimeProject({ + fsModule: fs, + runtimeDir, + }); + + expect(result.runtimeDir).toBe(runtimeDir); + expect( + JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), + ).toEqual({ + name: "alphaclaw-openclaw-runtime", + private: true, + }); + }); + + it("prepends the managed openclaw bin dir to PATH when a runtime exists", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const binDir = getManagedOpenclawBinDir({ runtimeDir }); + const binPath = getManagedOpenclawBinPath({ runtimeDir }); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(binPath, "#!/bin/sh\n"); + const env = { PATH: "/usr/local/bin:/usr/bin" }; + + const applied = prependManagedOpenclawBinToPath({ + env, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + }); + + expect(applied).toBe(true); + expect(env.PATH.split(path.delimiter)[0]).toBe(binDir); + }); + + it("does not change PATH when the managed runtime is absent", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const env = { PATH: "/usr/local/bin:/usr/bin" }; + + const applied = prependManagedOpenclawBinToPath({ + env, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + }); + + expect(applied).toBe(false); + expect(env.PATH).toBe("/usr/local/bin:/usr/bin"); + }); +}); diff --git a/tests/server/pending-openclaw-update.test.js b/tests/server/pending-openclaw-update.test.js index 846ba79b..0bd38673 100644 --- a/tests/server/pending-openclaw-update.test.js +++ b/tests/server/pending-openclaw-update.test.js @@ -73,6 +73,12 @@ describe("server/pending-openclaw-update", () => { timeout: 180000, }, ); + expect( + JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf8")), + ).toEqual({ + name: "alphaclaw-openclaw-runtime", + private: true, + }); expect(fs.existsSync(markerPath)).toBe(false); }); From bc5bd9122d656e0181ffd95f7bf9abbafb99437c Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 23:11:56 -0700 Subject: [PATCH 05/26] Seed managed OpenClaw runtime from bundled version --- bin/alphaclaw.js | 13 ++ lib/server/openclaw-runtime.js | 188 ++++++++++++++++++++++ lib/server/pending-openclaw-update.js | 18 +-- tests/server/openclaw-runtime.test.js | 222 ++++++++++++++++++++++++++ 4 files changed, 428 insertions(+), 13 deletions(-) diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index e53953ee..9920cccf 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -22,6 +22,7 @@ const { const { getManagedOpenclawRuntimeDir, prependManagedOpenclawBinToPath, + syncManagedOpenclawRuntimeWithBundled, } = require("../lib/server/openclaw-runtime"); const kUsageTrackerPluginPath = path.resolve( @@ -220,6 +221,18 @@ if (fs.existsSync(pendingOpenclawUpdateMarker)) { markerPath: pendingOpenclawUpdateMarker, }); } +try { + syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl: execSync, + fsModule: fs, + logger: console, + runtimeDir: managedOpenclawRuntimeDir, + }); +} catch (error) { + console.log( + `[alphaclaw] Could not sync managed OpenClaw runtime from bundled install: ${error.message}`, + ); +} prependManagedOpenclawBinToPath({ env: process.env, fsModule: fs, diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 7c8cfe13..61d2d830 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -2,6 +2,10 @@ const fs = require("fs"); const path = require("path"); const { kRootDir } = require("./constants"); +const { + compareVersionParts, + normalizeOpenclawVersion, +} = require("./helpers"); const getManagedOpenclawRuntimeDir = ({ rootDir = kRootDir } = {}) => path.join(rootDir, ".openclaw-runtime"); @@ -16,6 +20,14 @@ const getManagedOpenclawBinDir = ({ runtimeDir } = {}) => const getManagedOpenclawBinPath = ({ runtimeDir } = {}) => path.join(getManagedOpenclawBinDir({ runtimeDir }), "openclaw"); +const getManagedOpenclawPackageJsonPath = ({ runtimeDir } = {}) => + path.join( + runtimeDir || getManagedOpenclawRuntimeDir(), + "node_modules", + "openclaw", + "package.json", + ); + const ensureManagedOpenclawRuntimeProject = ({ fsModule = fs, runtimeDir, @@ -42,6 +54,176 @@ const ensureManagedOpenclawRuntimeProject = ({ }; }; +const readManagedOpenclawRuntimeVersion = ({ + fsModule = fs, + runtimeDir, +} = {}) => { + try { + const pkg = JSON.parse( + fsModule.readFileSync( + getManagedOpenclawPackageJsonPath({ runtimeDir }), + "utf8", + ), + ); + return normalizeOpenclawVersion(pkg?.version || ""); + } catch { + return null; + } +}; + +const readBundledOpenclawVersion = ({ + fsModule = fs, + resolveImpl = require.resolve, +} = {}) => { + try { + const pkgPath = resolveImpl("openclaw/package.json"); + const pkg = JSON.parse(fsModule.readFileSync(pkgPath, "utf8")); + return normalizeOpenclawVersion(pkg?.version || ""); + } catch { + return null; + } +}; + +const shellQuote = (value) => + `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; + +const applyManagedOpenclawPatch = ({ + execSyncImpl, + fsModule = fs, + logger = console, + runtimeDir, + version, + alphaclawRoot = path.resolve(__dirname, "..", ".."), +} = {}) => { + const normalizedVersion = normalizeOpenclawVersion(version); + if (!normalizedVersion) return false; + const patchesDir = path.join(alphaclawRoot, "patches"); + const patchFileName = `openclaw+${normalizedVersion}.patch`; + const patchFilePath = path.join(patchesDir, patchFileName); + if (!fsModule.existsSync(patchFilePath)) { + return false; + } + + const runtimePatchDirName = ".alphaclaw-patches"; + const runtimePatchDirPath = path.join(runtimeDir, runtimePatchDirName); + try { + if (fsModule.existsSync(runtimePatchDirPath)) { + fsModule.rmSync(runtimePatchDirPath, { recursive: true, force: true }); + } + } catch {} + fsModule.symlinkSync(patchesDir, runtimePatchDirPath); + + const patchPackageMain = require.resolve("patch-package/dist/index.js", { + paths: [alphaclawRoot], + }); + logger.log( + `[alphaclaw] Applying bundled OpenClaw patch for ${normalizedVersion}...`, + ); + execSyncImpl( + `${shellQuote(process.execPath)} ${shellQuote(patchPackageMain)} --patch-dir ${shellQuote(runtimePatchDirName)}`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 120000, + }, + ); + return true; +}; + +const installManagedOpenclawRuntime = ({ + execSyncImpl, + fsModule = fs, + logger = console, + runtimeDir, + spec, + alphaclawRoot, +} = {}) => { + const normalizedSpec = String(spec || "").trim() || "openclaw@latest"; + ensureManagedOpenclawRuntimeProject({ + fsModule, + runtimeDir, + }); + execSyncImpl( + `npm install ${shellQuote(normalizedSpec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + const installedVersion = readManagedOpenclawRuntimeVersion({ + fsModule, + runtimeDir, + }); + applyManagedOpenclawPatch({ + execSyncImpl, + fsModule, + logger, + runtimeDir, + version: installedVersion, + alphaclawRoot, + }); + return { + spec: normalizedSpec, + version: installedVersion, + }; +}; + +const syncManagedOpenclawRuntimeWithBundled = ({ + execSyncImpl, + fsModule = fs, + logger = console, + runtimeDir, + resolveImpl, + alphaclawRoot, +} = {}) => { + const bundledVersion = readBundledOpenclawVersion({ + fsModule, + resolveImpl, + }); + if (!bundledVersion) { + return { + checked: false, + synced: false, + bundledVersion: null, + runtimeVersion: readManagedOpenclawRuntimeVersion({ fsModule, runtimeDir }), + }; + } + + const runtimeVersion = readManagedOpenclawRuntimeVersion({ + fsModule, + runtimeDir, + }); + if (runtimeVersion && compareVersionParts(runtimeVersion, bundledVersion) >= 0) { + return { + checked: true, + synced: false, + bundledVersion, + runtimeVersion, + }; + } + + logger.log( + runtimeVersion + ? `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` + : `[alphaclaw] Managed OpenClaw runtime missing; seeding bundled OpenClaw ${bundledVersion}...`, + ); + const installResult = installManagedOpenclawRuntime({ + execSyncImpl, + fsModule, + logger, + runtimeDir, + spec: `openclaw@${bundledVersion}`, + alphaclawRoot, + }); + return { + checked: true, + synced: true, + bundledVersion, + runtimeVersion: installResult.version || bundledVersion, + }; +}; + const prependManagedOpenclawBinToPath = ({ env = process.env, fsModule = fs, @@ -64,9 +246,15 @@ const prependManagedOpenclawBinToPath = ({ }; module.exports = { + applyManagedOpenclawPatch, ensureManagedOpenclawRuntimeProject, getManagedOpenclawBinDir, getManagedOpenclawBinPath, + getManagedOpenclawPackageJsonPath, getManagedOpenclawRuntimeDir, + installManagedOpenclawRuntime, prependManagedOpenclawBinToPath, + readBundledOpenclawVersion, + readManagedOpenclawRuntimeVersion, + syncManagedOpenclawRuntimeWithBundled, }; diff --git a/lib/server/pending-openclaw-update.js b/lib/server/pending-openclaw-update.js index 8c526166..2823ca64 100644 --- a/lib/server/pending-openclaw-update.js +++ b/lib/server/pending-openclaw-update.js @@ -1,5 +1,5 @@ const { - ensureManagedOpenclawRuntimeProject, + installManagedOpenclawRuntime, } = require("./openclaw-runtime"); const buildPendingOpenclawInstallSpec = (marker = {}) => { @@ -11,9 +11,6 @@ const buildPendingOpenclawInstallSpec = (marker = {}) => { return `openclaw@${targetVersion}`; }; -const shellQuote = (value) => - `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; - const applyPendingOpenclawUpdate = ({ execSyncImpl, fsModule, @@ -40,18 +37,13 @@ const applyPendingOpenclawUpdate = ({ logger.log(`[alphaclaw] Pending OpenClaw update detected, installing ${spec}...`); try { - ensureManagedOpenclawRuntimeProject({ + installManagedOpenclawRuntime({ + execSyncImpl, fsModule, + logger, runtimeDir: installDir, + spec, }); - execSyncImpl( - `npm install ${shellQuote(spec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, - { - cwd: installDir, - stdio: "inherit", - timeout: 180000, - }, - ); fsModule.unlinkSync(markerPath); logger.log("[alphaclaw] OpenClaw update applied successfully"); return { diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index 8227cc09..e8f3ec4a 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -3,11 +3,17 @@ const os = require("os"); const path = require("path"); const { + applyManagedOpenclawPatch, ensureManagedOpenclawRuntimeProject, getManagedOpenclawBinDir, getManagedOpenclawBinPath, + getManagedOpenclawPackageJsonPath, getManagedOpenclawRuntimeDir, + installManagedOpenclawRuntime, prependManagedOpenclawBinToPath, + readBundledOpenclawVersion, + readManagedOpenclawRuntimeVersion, + syncManagedOpenclawRuntimeWithBundled, } = require("../../lib/server/openclaw-runtime"); describe("server/openclaw-runtime", () => { @@ -46,6 +52,222 @@ describe("server/openclaw-runtime", () => { }); }); + it("reads the managed runtime version from its package.json", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const openclawPkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir }); + fs.mkdirSync(path.dirname(openclawPkgPath), { recursive: true }); + fs.writeFileSync( + openclawPkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.5" }), + ); + + expect( + readManagedOpenclawRuntimeVersion({ + fsModule: fs, + runtimeDir, + }), + ).toBe("2026.4.5"); + }); + + it("reads the bundled OpenClaw version from the installed package metadata", () => { + const bundleDir = path.join(tmpDir, "bundle"); + const bundledPkgPath = path.join(bundleDir, "node_modules", "openclaw", "package.json"); + fs.mkdirSync(path.dirname(bundledPkgPath), { recursive: true }); + fs.writeFileSync( + bundledPkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.6" }), + ); + + expect( + readBundledOpenclawVersion({ + fsModule: fs, + resolveImpl: (request) => { + if (request === "openclaw/package.json") return bundledPkgPath; + throw new Error(`unexpected resolve ${request}`); + }, + }), + ).toBe("2026.4.6"); + }); + + it("applies a bundled patch when there is a matching patch file", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const alphaclawRoot = path.join(tmpDir, "alphaclaw"); + const patchPackageMain = path.join( + alphaclawRoot, + "node_modules", + "patch-package", + "dist", + "index.js", + ); + fs.mkdirSync(path.dirname(patchPackageMain), { recursive: true }); + fs.writeFileSync(patchPackageMain, "module.exports = {};\n"); + fs.mkdirSync(path.join(alphaclawRoot, "patches"), { recursive: true }); + fs.writeFileSync( + path.join(alphaclawRoot, "patches", "openclaw+2026.4.1.patch"), + "diff --git a/a b/b\n", + ); + fs.mkdirSync(runtimeDir, { recursive: true }); + const execSyncImpl = vi.fn(); + + const applied = applyManagedOpenclawPatch({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + version: "2026.4.1", + alphaclawRoot, + }); + + expect(applied).toBe(true); + expect(fs.lstatSync(path.join(runtimeDir, ".alphaclaw-patches")).isSymbolicLink()).toBe( + true, + ); + expect(execSyncImpl).toHaveBeenCalledWith( + expect.stringContaining("--patch-dir '.alphaclaw-patches'"), + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 120000, + }, + ); + }); + + it("installs into the managed runtime and patches the bundled version when needed", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const alphaclawRoot = path.join(tmpDir, "alphaclaw"); + const patchPackageMain = path.join( + alphaclawRoot, + "node_modules", + "patch-package", + "dist", + "index.js", + ); + fs.mkdirSync(path.dirname(patchPackageMain), { recursive: true }); + fs.writeFileSync(patchPackageMain, "module.exports = {};\n"); + fs.mkdirSync(path.join(alphaclawRoot, "patches"), { recursive: true }); + fs.writeFileSync( + path.join(alphaclawRoot, "patches", "openclaw+2026.4.1.patch"), + "diff --git a/a b/b\n", + ); + const execSyncImpl = vi.fn((command, options) => { + if (!String(command).includes("npm install")) return; + const pkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir: options.cwd }); + fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); + fs.writeFileSync( + pkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.1" }), + ); + }); + + const result = installManagedOpenclawRuntime({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + spec: "openclaw@2026.4.1", + alphaclawRoot, + }); + + expect(result).toEqual({ + spec: "openclaw@2026.4.1", + version: "2026.4.1", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + "npm install 'openclaw@2026.4.1' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + expect(execSyncImpl.mock.calls.some(([command]) => String(command).includes("patch-package"))).toBe( + true, + ); + }); + + it("seeds the managed runtime from the bundled OpenClaw version when missing", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const bundleDir = path.join(tmpDir, "bundle"); + const bundledPkgPath = path.join(bundleDir, "node_modules", "openclaw", "package.json"); + fs.mkdirSync(path.dirname(bundledPkgPath), { recursive: true }); + fs.writeFileSync( + bundledPkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.5" }), + ); + const execSyncImpl = vi.fn((command, options) => { + if (!String(command).includes("npm install")) return; + const pkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir: options.cwd }); + fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); + fs.writeFileSync( + pkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.5" }), + ); + }); + + const result = syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + resolveImpl: (request) => { + if (request === "openclaw/package.json") return bundledPkgPath; + throw new Error(`unexpected resolve ${request}`); + }, + alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "2026.4.5", + runtimeVersion: "2026.4.5", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + "npm install 'openclaw@2026.4.5' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + }); + + it("does not downgrade a newer managed runtime during bundled sync", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const bundledPkgPath = path.join(tmpDir, "bundle", "node_modules", "openclaw", "package.json"); + const runtimePkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir }); + fs.mkdirSync(path.dirname(bundledPkgPath), { recursive: true }); + fs.mkdirSync(path.dirname(runtimePkgPath), { recursive: true }); + fs.writeFileSync( + bundledPkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.1" }), + ); + fs.writeFileSync( + runtimePkgPath, + JSON.stringify({ name: "openclaw", version: "2026.4.5" }), + ); + const execSyncImpl = vi.fn(); + + const result = syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + resolveImpl: (request) => { + if (request === "openclaw/package.json") return bundledPkgPath; + throw new Error(`unexpected resolve ${request}`); + }, + }); + + expect(result).toEqual({ + checked: true, + synced: false, + bundledVersion: "2026.4.1", + runtimeVersion: "2026.4.5", + }); + expect(execSyncImpl).not.toHaveBeenCalled(); + }); + it("prepends the managed openclaw bin dir to PATH when a runtime exists", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const binDir = getManagedOpenclawBinDir({ runtimeDir }); From b9f66581192872dc5ce657f194a4e9de03700a04 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 23:12:57 -0700 Subject: [PATCH 06/26] 0.8.7-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f0cad08d..3f410ec6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.0", + "version": "0.8.7-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.0", + "version": "0.8.7-beta.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5e91cee4..755da277 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.0", + "version": "0.8.7-beta.1", "publishConfig": { "access": "public" }, From 48f6d9dd0eeaf07b73f765dff43d612e91ba7a97 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 23:31:42 -0700 Subject: [PATCH 07/26] Bootstrap AlphaClaw from a managed runtime --- bin/alphaclaw.js | 85 ++++++++ lib/server/alphaclaw-runtime.js | 180 +++++++++++++++++ lib/server/alphaclaw-version.js | 8 +- lib/server/pending-alphaclaw-update.js | 21 +- tests/bin/alphaclaw.test.js | 22 +- tests/server/alphaclaw-runtime.test.js | 188 ++++++++++++++++++ tests/server/pending-alphaclaw-update.test.js | 6 + 7 files changed, 495 insertions(+), 15 deletions(-) create mode 100644 lib/server/alphaclaw-runtime.js create mode 100644 tests/server/alphaclaw-runtime.test.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 9920cccf..8b7d8478 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -16,6 +16,11 @@ const { const { applyPendingAlphaclawUpdate, } = require("../lib/server/pending-alphaclaw-update"); +const { + getManagedAlphaclawCliPath, + getManagedAlphaclawRuntimeDir, + syncManagedAlphaclawRuntimeWithBundled, +} = require("../lib/server/alphaclaw-runtime"); const { applyPendingOpenclawUpdate, } = require("../lib/server/pending-openclaw-update"); @@ -173,10 +178,89 @@ if (portFlag) { process.env.PORT = portFlag; } +const kManagedAlphaclawRuntimeEnvFlag = "ALPHACLAW_MANAGED_RUNTIME_ACTIVE"; +const shouldBootstrapManagedAlphaclawRuntime = + command === "start" && + process.env[kManagedAlphaclawRuntimeEnvFlag] !== "1"; + // --------------------------------------------------------------------------- // 2. Create directory structure // --------------------------------------------------------------------------- +if (shouldBootstrapManagedAlphaclawRuntime) { + const { spawn } = require("child_process"); + const managedAlphaclawRuntimeDir = getManagedAlphaclawRuntimeDir({ rootDir }); + const pendingUpdateMarker = path.join(rootDir, ".alphaclaw-update-pending"); + if (fs.existsSync(pendingUpdateMarker)) { + applyPendingAlphaclawUpdate({ + execSyncImpl: execSync, + fsModule: fs, + installDir: managedAlphaclawRuntimeDir, + logger: console, + markerPath: pendingUpdateMarker, + }); + } + try { + syncManagedAlphaclawRuntimeWithBundled({ + execSyncImpl: execSync, + fsModule: fs, + logger: console, + runtimeDir: managedAlphaclawRuntimeDir, + }); + } catch (error) { + console.log( + `[alphaclaw] Could not sync managed AlphaClaw runtime from bundled install: ${error.message}`, + ); + } + + const managedAlphaclawCliPath = getManagedAlphaclawCliPath({ + runtimeDir: managedAlphaclawRuntimeDir, + }); + if (!fs.existsSync(managedAlphaclawCliPath)) { + console.error( + `[alphaclaw] Managed AlphaClaw runtime missing CLI at ${managedAlphaclawCliPath}`, + ); + process.exit(1); + } + + const runtimeChild = spawn( + process.argv[0], + [managedAlphaclawCliPath, ...process.argv.slice(2)], + { + stdio: "inherit", + env: { + ...process.env, + [kManagedAlphaclawRuntimeEnvFlag]: "1", + ALPHACLAW_BOOTSTRAP_CLI_PATH: __filename, + }, + }, + ); + + const forwardSignal = (signal) => { + if (runtimeChild.exitCode === null && !runtimeChild.killed) { + runtimeChild.kill(signal); + } + }; + + process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.on("SIGINT", () => forwardSignal("SIGINT")); + + runtimeChild.on("error", (error) => { + console.error( + `[alphaclaw] Managed AlphaClaw runtime launch failed: ${error.message}`, + ); + process.exit(1); + }); + + runtimeChild.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(Number.isInteger(code) ? code : 0); + }); +} else { + const openclawDir = path.join(rootDir, ".openclaw"); fs.mkdirSync(openclawDir, { recursive: true }); const { hourlyGitSyncPath } = migrateManagedInternalFiles({ @@ -952,3 +1036,4 @@ try { console.log("[alphaclaw] Setup complete -- starting server"); require("../lib/server.js"); +} diff --git a/lib/server/alphaclaw-runtime.js b/lib/server/alphaclaw-runtime.js new file mode 100644 index 00000000..120e7899 --- /dev/null +++ b/lib/server/alphaclaw-runtime.js @@ -0,0 +1,180 @@ +const fs = require("fs"); +const path = require("path"); + +const { kRootDir } = require("./constants"); +const { compareVersionParts } = require("./helpers"); + +const getManagedAlphaclawRuntimeDir = ({ rootDir = kRootDir } = {}) => + path.join(rootDir, ".alphaclaw-runtime"); + +const getManagedAlphaclawCliPath = ({ runtimeDir } = {}) => + path.join( + runtimeDir || getManagedAlphaclawRuntimeDir(), + "node_modules", + "@chrysb", + "alphaclaw", + "bin", + "alphaclaw.js", + ); + +const getManagedAlphaclawPackageJsonPath = ({ runtimeDir } = {}) => + path.join( + runtimeDir || getManagedAlphaclawRuntimeDir(), + "node_modules", + "@chrysb", + "alphaclaw", + "package.json", + ); + +const ensureManagedAlphaclawRuntimeProject = ({ + fsModule = fs, + runtimeDir, +} = {}) => { + const resolvedRuntimeDir = runtimeDir || getManagedAlphaclawRuntimeDir(); + const packageJsonPath = path.join(resolvedRuntimeDir, "package.json"); + fsModule.mkdirSync(resolvedRuntimeDir, { recursive: true }); + if (!fsModule.existsSync(packageJsonPath)) { + fsModule.writeFileSync( + packageJsonPath, + JSON.stringify( + { + name: "alphaclaw-runtime", + private: true, + }, + null, + 2, + ), + ); + } + return { + runtimeDir: resolvedRuntimeDir, + packageJsonPath, + }; +}; + +const readManagedAlphaclawRuntimeVersion = ({ + fsModule = fs, + runtimeDir, +} = {}) => { + try { + const pkg = JSON.parse( + fsModule.readFileSync( + getManagedAlphaclawPackageJsonPath({ runtimeDir }), + "utf8", + ), + ); + return String(pkg?.version || "").trim() || null; + } catch { + return null; + } +}; + +const readBundledAlphaclawVersion = ({ + fsModule = fs, + packageJsonPath = path.resolve(__dirname, "..", "..", "package.json"), +} = {}) => { + try { + const pkg = JSON.parse(fsModule.readFileSync(packageJsonPath, "utf8")); + return String(pkg?.version || "").trim() || null; + } catch { + return null; + } +}; + +const shellQuote = (value) => + `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; + +const installManagedAlphaclawRuntime = ({ + execSyncImpl, + fsModule = fs, + runtimeDir, + spec, +} = {}) => { + const normalizedSpec = + String(spec || "").trim() || "@chrysb/alphaclaw@latest"; + ensureManagedAlphaclawRuntimeProject({ + fsModule, + runtimeDir, + }); + execSyncImpl( + `npm install ${shellQuote(normalizedSpec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + return { + spec: normalizedSpec, + version: readManagedAlphaclawRuntimeVersion({ + fsModule, + runtimeDir, + }), + }; +}; + +const syncManagedAlphaclawRuntimeWithBundled = ({ + execSyncImpl, + fsModule = fs, + logger = console, + runtimeDir, + packageJsonPath, +} = {}) => { + const bundledVersion = readBundledAlphaclawVersion({ + fsModule, + packageJsonPath, + }); + if (!bundledVersion) { + return { + checked: false, + synced: false, + bundledVersion: null, + runtimeVersion: readManagedAlphaclawRuntimeVersion({ + fsModule, + runtimeDir, + }), + }; + } + + const runtimeVersion = readManagedAlphaclawRuntimeVersion({ + fsModule, + runtimeDir, + }); + if (runtimeVersion && compareVersionParts(runtimeVersion, bundledVersion) >= 0) { + return { + checked: true, + synced: false, + bundledVersion, + runtimeVersion, + }; + } + + logger.log( + runtimeVersion + ? `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` + : `[alphaclaw] Managed AlphaClaw runtime missing; seeding bundled AlphaClaw ${bundledVersion}...`, + ); + const installResult = installManagedAlphaclawRuntime({ + execSyncImpl, + fsModule, + runtimeDir, + spec: `@chrysb/alphaclaw@${bundledVersion}`, + }); + return { + checked: true, + synced: true, + bundledVersion, + runtimeVersion: installResult.version || bundledVersion, + }; +}; + +module.exports = { + ensureManagedAlphaclawRuntimeProject, + getManagedAlphaclawCliPath, + getManagedAlphaclawPackageJsonPath, + getManagedAlphaclawRuntimeDir, + installManagedAlphaclawRuntime, + readBundledAlphaclawVersion, + readManagedAlphaclawRuntimeVersion, + syncManagedAlphaclawRuntimeWithBundled, +}; diff --git a/lib/server/alphaclaw-version.js b/lib/server/alphaclaw-version.js index 382eef0f..afda2717 100644 --- a/lib/server/alphaclaw-version.js +++ b/lib/server/alphaclaw-version.js @@ -126,9 +126,15 @@ const createAlphaclawVersionService = () => { // On bare metal / Mac / Linux, spawn a replacement process then exit. console.log("[alphaclaw] Spawning new process and exiting..."); const { spawn } = require("child_process"); - const child = spawn(process.argv[0], process.argv.slice(1), { + const nextEnv = { ...process.env }; + delete nextEnv.ALPHACLAW_MANAGED_RUNTIME_ACTIVE; + const bootstrapCliPath = + String(process.env.ALPHACLAW_BOOTSTRAP_CLI_PATH || "").trim() || + process.argv[1]; + const child = spawn(process.argv[0], [bootstrapCliPath, ...process.argv.slice(2)], { detached: true, stdio: "inherit", + env: nextEnv, }); child.unref(); process.exit(0); diff --git a/lib/server/pending-alphaclaw-update.js b/lib/server/pending-alphaclaw-update.js index 1f2339ad..a95b0850 100644 --- a/lib/server/pending-alphaclaw-update.js +++ b/lib/server/pending-alphaclaw-update.js @@ -1,3 +1,7 @@ +const { + installManagedAlphaclawRuntime, +} = require("./alphaclaw-runtime"); + const buildPendingAlphaclawInstallSpec = (marker = {}) => { const explicitSpec = String(marker?.spec || "").trim(); if (explicitSpec) { @@ -7,9 +11,6 @@ const buildPendingAlphaclawInstallSpec = (marker = {}) => { return `@chrysb/alphaclaw@${targetVersion}`; }; -const shellQuote = (value) => - `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; - const applyPendingAlphaclawUpdate = ({ execSyncImpl, fsModule, @@ -36,14 +37,12 @@ const applyPendingAlphaclawUpdate = ({ logger.log(`[alphaclaw] Pending update detected, installing ${spec}...`); try { - execSyncImpl( - `npm install ${shellQuote(spec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, - { - cwd: installDir, - stdio: "inherit", - timeout: 180000, - }, - ); + installManagedAlphaclawRuntime({ + execSyncImpl, + fsModule, + runtimeDir: installDir, + spec, + }); fsModule.unlinkSync(markerPath); logger.log("[alphaclaw] Update applied successfully"); return { diff --git a/tests/bin/alphaclaw.test.js b/tests/bin/alphaclaw.test.js index 1b4c8d6e..6c6007ce 100644 --- a/tests/bin/alphaclaw.test.js +++ b/tests/bin/alphaclaw.test.js @@ -27,7 +27,12 @@ describe("bin/alphaclaw port check", () => { execSync(`ALPHACLAW_ROOT_DIR="${tmpDir}" node "${binPath}" start`, { stdio: "pipe", encoding: "utf8", - env: { ...process.env, PORT: "18789", ALPHACLAW_ROOT_DIR: tmpDir } + env: { + ...process.env, + PORT: "18789", + ALPHACLAW_ROOT_DIR: tmpDir, + ALPHACLAW_MANAGED_RUNTIME_ACTIVE: "1", + } }); } catch (e) { status = e.status; @@ -46,7 +51,12 @@ describe("bin/alphaclaw port check", () => { execSync(`ALPHACLAW_ROOT_DIR="${tmpDir}" node "${binPath}" start --port 18789`, { stdio: "pipe", encoding: "utf8", - env: { ...process.env, PORT: "3000", ALPHACLAW_ROOT_DIR: tmpDir } + env: { + ...process.env, + PORT: "3000", + ALPHACLAW_ROOT_DIR: tmpDir, + ALPHACLAW_MANAGED_RUNTIME_ACTIVE: "1", + } }); } catch (e) { status = e.status; @@ -66,7 +76,13 @@ describe("bin/alphaclaw port check", () => { execSync(`ALPHACLAW_ROOT_DIR="${tmpDir}" node "${binPath}" start`, { stdio: "pipe", encoding: "utf8", - env: { ...process.env, PORT: "3001", ALPHACLAW_ROOT_DIR: tmpDir, SETUP_PASSWORD: "" } + env: { + ...process.env, + PORT: "3001", + ALPHACLAW_ROOT_DIR: tmpDir, + SETUP_PASSWORD: "", + ALPHACLAW_MANAGED_RUNTIME_ACTIVE: "1", + } }); } catch (e) { status = e.status; diff --git a/tests/server/alphaclaw-runtime.test.js b/tests/server/alphaclaw-runtime.test.js new file mode 100644 index 00000000..c3512218 --- /dev/null +++ b/tests/server/alphaclaw-runtime.test.js @@ -0,0 +1,188 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + ensureManagedAlphaclawRuntimeProject, + getManagedAlphaclawCliPath, + getManagedAlphaclawPackageJsonPath, + getManagedAlphaclawRuntimeDir, + installManagedAlphaclawRuntime, + readBundledAlphaclawVersion, + readManagedAlphaclawRuntimeVersion, + syncManagedAlphaclawRuntimeWithBundled, +} = require("../../lib/server/alphaclaw-runtime"); + +describe("server/alphaclaw-runtime", () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-runtime-")); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + it("builds the managed runtime directory under the AlphaClaw root", () => { + expect(getManagedAlphaclawRuntimeDir({ rootDir: tmpDir })).toBe( + path.join(tmpDir, ".alphaclaw-runtime"), + ); + }); + + it("seeds a minimal runtime package.json when needed", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + + const result = ensureManagedAlphaclawRuntimeProject({ + fsModule: fs, + runtimeDir, + }); + + expect(result.runtimeDir).toBe(runtimeDir); + expect( + JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), + ).toEqual({ + name: "alphaclaw-runtime", + private: true, + }); + }); + + it("reads the managed runtime version from its package.json", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const pkgPath = getManagedAlphaclawPackageJsonPath({ runtimeDir }); + fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); + fs.writeFileSync( + pkgPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.7" }), + ); + + expect( + readManagedAlphaclawRuntimeVersion({ + fsModule: fs, + runtimeDir, + }), + ).toBe("0.8.7"); + }); + + it("reads the bundled AlphaClaw version from package.json", () => { + const packageJsonPath = path.join(tmpDir, "package.json"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.8" }), + ); + + expect( + readBundledAlphaclawVersion({ + fsModule: fs, + packageJsonPath, + }), + ).toBe("0.8.8"); + }); + + it("installs into the managed runtime", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const execSyncImpl = vi.fn((command, options) => { + const pkgPath = getManagedAlphaclawPackageJsonPath({ runtimeDir: options.cwd }); + fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); + fs.writeFileSync( + pkgPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.7" }), + ); + }); + + const result = installManagedAlphaclawRuntime({ + execSyncImpl, + fsModule: fs, + runtimeDir, + spec: "@chrysb/alphaclaw@0.8.7", + }); + + expect(result).toEqual({ + spec: "@chrysb/alphaclaw@0.8.7", + version: "0.8.7", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + "npm install '@chrysb/alphaclaw@0.8.7' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + expect(fs.existsSync(getManagedAlphaclawCliPath({ runtimeDir }))).toBe(false); + }); + + it("seeds the managed runtime from the bundled AlphaClaw version when missing", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const packageJsonPath = path.join(tmpDir, "package.json"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.9" }), + ); + const execSyncImpl = vi.fn((command, options) => { + const pkgPath = getManagedAlphaclawPackageJsonPath({ runtimeDir: options.cwd }); + fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); + fs.writeFileSync( + pkgPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.9" }), + ); + }); + + const result = syncManagedAlphaclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + packageJsonPath, + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "0.8.9", + runtimeVersion: "0.8.9", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + "npm install '@chrysb/alphaclaw@0.8.9' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + }); + + it("does not downgrade a newer managed runtime during bundled sync", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const packageJsonPath = path.join(tmpDir, "package.json"); + const runtimePkgPath = getManagedAlphaclawPackageJsonPath({ runtimeDir }); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.8" }), + ); + fs.mkdirSync(path.dirname(runtimePkgPath), { recursive: true }); + fs.writeFileSync( + runtimePkgPath, + JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.9" }), + ); + const execSyncImpl = vi.fn(); + + const result = syncManagedAlphaclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + packageJsonPath, + }); + + expect(result).toEqual({ + checked: true, + synced: false, + bundledVersion: "0.8.8", + runtimeVersion: "0.8.9", + }); + expect(execSyncImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/server/pending-alphaclaw-update.test.js b/tests/server/pending-alphaclaw-update.test.js index 390a1a90..1c6cb3bd 100644 --- a/tests/server/pending-alphaclaw-update.test.js +++ b/tests/server/pending-alphaclaw-update.test.js @@ -75,6 +75,12 @@ describe("server/pending-alphaclaw-update", () => { timeout: 180000, }, ); + expect( + JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf8")), + ).toEqual({ + name: "alphaclaw-runtime", + private: true, + }); expect(fs.existsSync(markerPath)).toBe(false); }); From 3bff33b2ed5b9f050fac58e05537a56976f895a5 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 23:32:39 -0700 Subject: [PATCH 08/26] 0.8.7-beta.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3f410ec6..884e6aeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.1", + "version": "0.8.7-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.1", + "version": "0.8.7-beta.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 755da277..323de34e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.1", + "version": "0.8.7-beta.2", "publishConfig": { "access": "public" }, From 09570256b1925332336389ee575d05d42dd9738f Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 23:39:58 -0700 Subject: [PATCH 09/26] Canonicalize usage-tracker plugin paths --- bin/alphaclaw.js | 24 +++-------- lib/server/usage-tracker-config.js | 30 +++++++++++-- tests/server/onboarding-openclaw.test.js | 12 ++++-- tests/server/usage-tracker-config.test.js | 51 +++++++++++++++++++++++ 4 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 tests/server/usage-tracker-config.test.js diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 8b7d8478..0f467ccc 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -29,14 +29,10 @@ const { prependManagedOpenclawBinToPath, syncManagedOpenclawRuntimeWithBundled, } = require("../lib/server/openclaw-runtime"); - -const kUsageTrackerPluginPath = path.resolve( - __dirname, - "..", - "lib", - "plugin", - "usage-tracker", -); +const { + ensurePluginsShell, + ensureUsageTrackerPluginEntry, +} = require("../lib/server/usage-tracker-config"); // --------------------------------------------------------------------------- // Parse CLI flags @@ -900,10 +896,7 @@ if (fs.existsSync(configPath)) { try { const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); if (!cfg.channels) cfg.channels = {}; - if (!cfg.plugins) cfg.plugins = {}; - if (!cfg.plugins.load) cfg.plugins.load = {}; - if (!Array.isArray(cfg.plugins.load.paths)) cfg.plugins.load.paths = []; - if (!cfg.plugins.entries) cfg.plugins.entries = {}; + ensurePluginsShell(cfg); let changed = false; if (process.env.TELEGRAM_BOT_TOKEN && !cfg.channels.telegram) { @@ -929,12 +922,7 @@ if (fs.existsSync(configPath)) { console.log("[alphaclaw] Discord added"); changed = true; } - if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { - cfg.plugins.load.paths.push(kUsageTrackerPluginPath); - changed = true; - } - if (cfg.plugins.entries["usage-tracker"]?.enabled !== true) { - cfg.plugins.entries["usage-tracker"] = { enabled: true }; + if (ensureUsageTrackerPluginEntry(cfg)) { changed = true; } diff --git a/lib/server/usage-tracker-config.js b/lib/server/usage-tracker-config.js index 09636e7a..2a3271e4 100644 --- a/lib/server/usage-tracker-config.js +++ b/lib/server/usage-tracker-config.js @@ -8,6 +8,27 @@ const kUsageTrackerPluginPath = path.resolve( "usage-tracker", ); +const normalizePluginPath = (value = "") => + String(value || "") + .trim() + .replace(/\\/g, "/") + .replace(/\/+$/, ""); + +const isUsageTrackerPluginPath = (value = "") => { + const normalizedValue = normalizePluginPath(value); + const normalizedCanonicalPath = normalizePluginPath(kUsageTrackerPluginPath); + if (!normalizedValue) return false; + if ( + normalizedValue === normalizedCanonicalPath || + normalizedValue.startsWith(`${normalizedCanonicalPath}/`) + ) { + return true; + } + return /(?:^|\/)(?:node_modules\/@chrysb\/alphaclaw\/lib|lib)\/plugin\/usage-tracker(?:\/.*)?$/.test( + normalizedValue, + ); +}; + const ensurePluginsShell = (cfg = {}) => { if (!cfg.plugins || typeof cfg.plugins !== "object") cfg.plugins = {}; if (!Array.isArray(cfg.plugins.allow)) cfg.plugins.allow = []; @@ -32,9 +53,11 @@ const ensurePluginAllowed = ({ cfg = {}, pluginKey = "" }) => { const ensureUsageTrackerPluginEntry = (cfg = {}) => { const before = JSON.stringify(cfg); ensurePluginAllowed({ cfg, pluginKey: "usage-tracker" }); - if (!cfg.plugins.load.paths.includes(kUsageTrackerPluginPath)) { - cfg.plugins.load.paths.push(kUsageTrackerPluginPath); - } + const nextPaths = cfg.plugins.load.paths.filter( + (entry) => !isUsageTrackerPluginPath(entry), + ); + nextPaths.push(kUsageTrackerPluginPath); + cfg.plugins.load.paths = nextPaths; cfg.plugins.entries["usage-tracker"] = { ...(cfg.plugins.entries["usage-tracker"] && typeof cfg.plugins.entries["usage-tracker"] === "object" @@ -64,6 +87,7 @@ const ensureUsageTrackerPluginConfig = ({ fsModule, openclawDir }) => { module.exports = { kUsageTrackerPluginPath, + isUsageTrackerPluginPath, ensurePluginsShell, ensurePluginAllowed, ensureUsageTrackerPluginEntry, diff --git a/tests/server/onboarding-openclaw.test.js b/tests/server/onboarding-openclaw.test.js index 10aff366..cb676301 100644 --- a/tests/server/onboarding-openclaw.test.js +++ b/tests/server/onboarding-openclaw.test.js @@ -7,6 +7,9 @@ const { writeManagedImportOpenclawConfig, writeSanitizedOpenclawConfig, } = require("../../lib/server/onboarding/openclaw"); +const { + kUsageTrackerPluginPath, +} = require("../../lib/server/usage-tracker-config"); const createTempOpenclawDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-onboarding-openclaw-test-")); @@ -36,14 +39,17 @@ describe("server/onboarding/openclaw", () => { it("only scrubs exact secret string values in JSON", () => { const openclawDir = createTempOpenclawDir(); const configPath = path.join(openclawDir, "openclaw.json"); - const pluginPath = "/app/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker"; fs.writeFileSync( configPath, JSON.stringify( { plugins: { allow: ["memory-core"], - load: { paths: [pluginPath] }, + load: { + paths: [ + "/app/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker/index.js", + ], + }, entries: {}, }, channels: {}, @@ -64,7 +70,7 @@ describe("server/onboarding/openclaw", () => { const next = JSON.parse(fs.readFileSync(configPath, "utf8")); expect(next.notes).toBe("${GOG_KEYRING_PASSWORD}"); expect(next.plugins.allow).toEqual(["memory-core", "usage-tracker"]); - expect(next.plugins.load.paths).toContain(pluginPath); + expect(next.plugins.load.paths).toEqual([kUsageTrackerPluginPath]); expect(next.plugins.load.paths).not.toContain( "/app/node_modules/@chrysb/${GOG_KEYRING_PASSWORD}/lib/plugin/usage-tracker", ); diff --git a/tests/server/usage-tracker-config.test.js b/tests/server/usage-tracker-config.test.js new file mode 100644 index 00000000..38c34d7a --- /dev/null +++ b/tests/server/usage-tracker-config.test.js @@ -0,0 +1,51 @@ +const { + ensureUsageTrackerPluginEntry, + kUsageTrackerPluginPath, +} = require("../../lib/server/usage-tracker-config"); + +describe("server/usage-tracker-config", () => { + it("replaces legacy alphaclaw usage-tracker paths with the canonical path", () => { + const cfg = { + plugins: { + allow: [], + load: { + paths: [ + "/app/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker", + "/data/.alphaclaw-runtime/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker", + "/data/.alphaclaw-runtime/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker/index.js", + "/data/.alphaclaw-runtime/node_modules/@chrysb/alphaclaw/lib/plugin/usage-tracker/openclaw.plugin.json", + "/custom/plugins/other-plugin", + ], + }, + entries: {}, + }, + }; + + const changed = ensureUsageTrackerPluginEntry(cfg); + + expect(changed).toBe(true); + expect(cfg.plugins.allow).toContain("usage-tracker"); + expect(cfg.plugins.load.paths).toEqual([ + "/custom/plugins/other-plugin", + kUsageTrackerPluginPath, + ]); + expect(cfg.plugins.entries["usage-tracker"]).toEqual({ enabled: true }); + }); + + it("is a no-op when the canonical usage-tracker path is already present", () => { + const cfg = { + plugins: { + allow: ["usage-tracker"], + load: { paths: [kUsageTrackerPluginPath] }, + entries: { + "usage-tracker": { enabled: true }, + }, + }, + }; + + const changed = ensureUsageTrackerPluginEntry(cfg); + + expect(changed).toBe(false); + expect(cfg.plugins.load.paths).toEqual([kUsageTrackerPluginPath]); + }); +}); From aa61f0ae2d6738b3224e962cb86a3e89edbc3058 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Sun, 5 Apr 2026 23:47:27 -0700 Subject: [PATCH 10/26] Sync managed runtimes from bundled packages --- lib/server/alphaclaw-runtime.js | 72 ++++++++++---- lib/server/openclaw-runtime.js | 68 +++++++++---- lib/server/package-fingerprint.js | 119 +++++++++++++++++++++++ tests/server/alphaclaw-runtime.test.js | 121 ++++++++++++++++++++---- tests/server/openclaw-runtime.test.js | 126 +++++++++++++++++++------ 5 files changed, 421 insertions(+), 85 deletions(-) create mode 100644 lib/server/package-fingerprint.js diff --git a/lib/server/alphaclaw-runtime.js b/lib/server/alphaclaw-runtime.js index 120e7899..1f867072 100644 --- a/lib/server/alphaclaw-runtime.js +++ b/lib/server/alphaclaw-runtime.js @@ -3,26 +3,31 @@ const path = require("path"); const { kRootDir } = require("./constants"); const { compareVersionParts } = require("./helpers"); +const { computePackageFingerprint } = require("./package-fingerprint"); const getManagedAlphaclawRuntimeDir = ({ rootDir = kRootDir } = {}) => path.join(rootDir, ".alphaclaw-runtime"); -const getManagedAlphaclawCliPath = ({ runtimeDir } = {}) => +const getBundledAlphaclawPackageRoot = () => path.resolve(__dirname, "..", ".."); + +const getManagedAlphaclawPackageRoot = ({ runtimeDir } = {}) => path.join( runtimeDir || getManagedAlphaclawRuntimeDir(), "node_modules", "@chrysb", "alphaclaw", + ); + +const getManagedAlphaclawCliPath = ({ runtimeDir } = {}) => + path.join( + getManagedAlphaclawPackageRoot({ runtimeDir }), "bin", "alphaclaw.js", ); const getManagedAlphaclawPackageJsonPath = ({ runtimeDir } = {}) => path.join( - runtimeDir || getManagedAlphaclawRuntimeDir(), - "node_modules", - "@chrysb", - "alphaclaw", + getManagedAlphaclawPackageRoot({ runtimeDir }), "package.json", ); @@ -89,9 +94,12 @@ const installManagedAlphaclawRuntime = ({ fsModule = fs, runtimeDir, spec, + sourcePath, } = {}) => { - const normalizedSpec = - String(spec || "").trim() || "@chrysb/alphaclaw@latest"; + const normalizedSourcePath = String(sourcePath || "").trim(); + const normalizedSpec = normalizedSourcePath + ? normalizedSourcePath + : String(spec || "").trim() || "@chrysb/alphaclaw@latest"; ensureManagedAlphaclawRuntimeProject({ fsModule, runtimeDir, @@ -118,11 +126,12 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ fsModule = fs, logger = console, runtimeDir, + packageRoot = getBundledAlphaclawPackageRoot(), packageJsonPath, } = {}) => { const bundledVersion = readBundledAlphaclawVersion({ fsModule, - packageJsonPath, + packageJsonPath: packageJsonPath || path.join(packageRoot, "package.json"), }); if (!bundledVersion) { return { @@ -140,25 +149,46 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ fsModule, runtimeDir, }); + const runtimePackageRoot = getManagedAlphaclawPackageRoot({ runtimeDir }); + const bundledFingerprint = computePackageFingerprint({ + fsModule, + packageRoot, + packageJsonPath: packageJsonPath || path.join(packageRoot, "package.json"), + }); + const runtimeFingerprint = computePackageFingerprint({ + fsModule, + packageRoot: runtimePackageRoot, + packageJsonPath: getManagedAlphaclawPackageJsonPath({ runtimeDir }), + }); if (runtimeVersion && compareVersionParts(runtimeVersion, bundledVersion) >= 0) { - return { - checked: true, - synced: false, - bundledVersion, - runtimeVersion, - }; + if ( + compareVersionParts(runtimeVersion, bundledVersion) > 0 || + !bundledFingerprint || + runtimeFingerprint === bundledFingerprint + ) { + return { + checked: true, + synced: false, + bundledVersion, + runtimeVersion, + }; + } + logger.log( + `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} differs from bundled ${bundledVersion}; refreshing runtime...`, + ); + } else { + logger.log( + runtimeVersion + ? `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` + : `[alphaclaw] Managed AlphaClaw runtime missing; seeding bundled AlphaClaw ${bundledVersion}...`, + ); } - logger.log( - runtimeVersion - ? `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` - : `[alphaclaw] Managed AlphaClaw runtime missing; seeding bundled AlphaClaw ${bundledVersion}...`, - ); const installResult = installManagedAlphaclawRuntime({ execSyncImpl, fsModule, runtimeDir, - spec: `@chrysb/alphaclaw@${bundledVersion}`, + sourcePath: packageRoot, }); return { checked: true, @@ -170,8 +200,10 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ module.exports = { ensureManagedAlphaclawRuntimeProject, + getBundledAlphaclawPackageRoot, getManagedAlphaclawCliPath, getManagedAlphaclawPackageJsonPath, + getManagedAlphaclawPackageRoot, getManagedAlphaclawRuntimeDir, installManagedAlphaclawRuntime, readBundledAlphaclawVersion, diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 61d2d830..07ce0ce3 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -6,10 +6,22 @@ const { compareVersionParts, normalizeOpenclawVersion, } = require("./helpers"); +const { computePackageFingerprint } = require("./package-fingerprint"); const getManagedOpenclawRuntimeDir = ({ rootDir = kRootDir } = {}) => path.join(rootDir, ".openclaw-runtime"); +const getBundledOpenclawPackageRoot = ({ + resolveImpl = require.resolve, +} = {}) => path.dirname(resolveImpl("openclaw/package.json")); + +const getManagedOpenclawPackageRoot = ({ runtimeDir } = {}) => + path.join( + runtimeDir || getManagedOpenclawRuntimeDir(), + "node_modules", + "openclaw", + ); + const getManagedOpenclawBinDir = ({ runtimeDir } = {}) => path.join( runtimeDir || getManagedOpenclawRuntimeDir(), @@ -22,9 +34,7 @@ const getManagedOpenclawBinPath = ({ runtimeDir } = {}) => const getManagedOpenclawPackageJsonPath = ({ runtimeDir } = {}) => path.join( - runtimeDir || getManagedOpenclawRuntimeDir(), - "node_modules", - "openclaw", + getManagedOpenclawPackageRoot({ runtimeDir }), "package.json", ); @@ -136,9 +146,13 @@ const installManagedOpenclawRuntime = ({ logger = console, runtimeDir, spec, + sourcePath, alphaclawRoot, } = {}) => { - const normalizedSpec = String(spec || "").trim() || "openclaw@latest"; + const normalizedSourcePath = String(sourcePath || "").trim(); + const normalizedSpec = normalizedSourcePath + ? normalizedSourcePath + : String(spec || "").trim() || "openclaw@latest"; ensureManagedOpenclawRuntimeProject({ fsModule, runtimeDir, @@ -177,6 +191,7 @@ const syncManagedOpenclawRuntimeWithBundled = ({ resolveImpl, alphaclawRoot, } = {}) => { + const bundledPackageRoot = getBundledOpenclawPackageRoot({ resolveImpl }); const bundledVersion = readBundledOpenclawVersion({ fsModule, resolveImpl, @@ -194,26 +209,45 @@ const syncManagedOpenclawRuntimeWithBundled = ({ fsModule, runtimeDir, }); + const bundledFingerprint = computePackageFingerprint({ + fsModule, + packageRoot: bundledPackageRoot, + }); + const runtimeFingerprint = computePackageFingerprint({ + fsModule, + packageRoot: getManagedOpenclawPackageRoot({ runtimeDir }), + packageJsonPath: getManagedOpenclawPackageJsonPath({ runtimeDir }), + }); if (runtimeVersion && compareVersionParts(runtimeVersion, bundledVersion) >= 0) { - return { - checked: true, - synced: false, - bundledVersion, - runtimeVersion, - }; + if ( + compareVersionParts(runtimeVersion, bundledVersion) > 0 || + !bundledFingerprint || + runtimeFingerprint === bundledFingerprint + ) { + return { + checked: true, + synced: false, + bundledVersion, + runtimeVersion, + }; + } + logger.log( + `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} differs from bundled ${bundledVersion}; refreshing runtime...`, + ); + } else { + logger.log( + runtimeVersion + ? `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` + : `[alphaclaw] Managed OpenClaw runtime missing; seeding bundled OpenClaw ${bundledVersion}...`, + ); } - logger.log( - runtimeVersion - ? `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` - : `[alphaclaw] Managed OpenClaw runtime missing; seeding bundled OpenClaw ${bundledVersion}...`, - ); const installResult = installManagedOpenclawRuntime({ execSyncImpl, fsModule, logger, runtimeDir, - spec: `openclaw@${bundledVersion}`, + sourcePath: bundledPackageRoot, alphaclawRoot, }); return { @@ -248,8 +282,10 @@ const prependManagedOpenclawBinToPath = ({ module.exports = { applyManagedOpenclawPatch, ensureManagedOpenclawRuntimeProject, + getBundledOpenclawPackageRoot, getManagedOpenclawBinDir, getManagedOpenclawBinPath, + getManagedOpenclawPackageRoot, getManagedOpenclawPackageJsonPath, getManagedOpenclawRuntimeDir, installManagedOpenclawRuntime, diff --git a/lib/server/package-fingerprint.js b/lib/server/package-fingerprint.js new file mode 100644 index 00000000..bb9c252f --- /dev/null +++ b/lib/server/package-fingerprint.js @@ -0,0 +1,119 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const kIgnoredDirectoryNames = new Set([".git", "node_modules"]); + +const normalizeRelativePath = (packageRoot, absolutePath) => + path.relative(packageRoot, absolutePath).split(path.sep).join("/"); + +const addIncludedPath = ({ includeSet, value }) => { + const normalizedValue = String(value || "").trim(); + if (!normalizedValue) return; + includeSet.add(normalizedValue.replace(/\/+$/, "")); +}; + +const collectIncludedPaths = ({ packageJson = {} } = {}) => { + const includeSet = new Set(["package.json"]); + + if (Array.isArray(packageJson.files)) { + for (const entry of packageJson.files) { + addIncludedPath({ includeSet, value: entry }); + } + } + + if (typeof packageJson.bin === "string") { + addIncludedPath({ includeSet, value: packageJson.bin }); + } else if (packageJson.bin && typeof packageJson.bin === "object") { + for (const entry of Object.values(packageJson.bin)) { + addIncludedPath({ includeSet, value: entry }); + } + } + + return Array.from(includeSet).sort((left, right) => left.localeCompare(right)); +}; + +const walkIncludedFiles = ({ + fsModule = fs, + packageRoot, + absolutePath, + files, +}) => { + if (!fsModule.existsSync(absolutePath)) return; + const relativePath = normalizeRelativePath(packageRoot, absolutePath); + if (!relativePath || relativePath.startsWith("..")) return; + + const stat = fsModule.lstatSync(absolutePath); + if (stat.isSymbolicLink()) { + files.push({ + relativePath, + hash: `symlink:${fsModule.readlinkSync(absolutePath)}`, + }); + return; + } + if (stat.isFile()) { + files.push({ + relativePath, + hash: crypto + .createHash("sha256") + .update(fsModule.readFileSync(absolutePath)) + .digest("hex"), + }); + return; + } + if (!stat.isDirectory()) return; + + const entries = fsModule + .readdirSync(absolutePath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of entries) { + if (entry.isDirectory() && kIgnoredDirectoryNames.has(entry.name)) continue; + walkIncludedFiles({ + fsModule, + packageRoot, + absolutePath: path.join(absolutePath, entry.name), + files, + }); + } +}; + +const computePackageFingerprint = ({ + fsModule = fs, + packageRoot, + packageJsonPath = path.join(packageRoot, "package.json"), +} = {}) => { + const resolvedPackageRoot = path.resolve(String(packageRoot || "")); + if (!resolvedPackageRoot || !fsModule.existsSync(packageJsonPath)) return null; + + let packageJson; + try { + packageJson = JSON.parse(fsModule.readFileSync(packageJsonPath, "utf8")); + } catch { + return null; + } + + const files = []; + for (const includePath of collectIncludedPaths({ packageJson })) { + walkIncludedFiles({ + fsModule, + packageRoot: resolvedPackageRoot, + absolutePath: path.resolve(resolvedPackageRoot, includePath), + files, + }); + } + + const hash = crypto.createHash("sha256"); + hash.update("package-fingerprint-v1"); + for (const entry of files.sort((left, right) => left.relativePath.localeCompare(right.relativePath))) { + hash.update(entry.relativePath); + hash.update("\0"); + hash.update(entry.hash); + hash.update("\0"); + } + return hash.digest("hex"); +}; + +module.exports = { + computePackageFingerprint, +}; diff --git a/tests/server/alphaclaw-runtime.test.js b/tests/server/alphaclaw-runtime.test.js index c3512218..e32ba82a 100644 --- a/tests/server/alphaclaw-runtime.test.js +++ b/tests/server/alphaclaw-runtime.test.js @@ -6,6 +6,7 @@ const { ensureManagedAlphaclawRuntimeProject, getManagedAlphaclawCliPath, getManagedAlphaclawPackageJsonPath, + getManagedAlphaclawPackageRoot, getManagedAlphaclawRuntimeDir, installManagedAlphaclawRuntime, readBundledAlphaclawVersion, @@ -13,6 +14,38 @@ const { syncManagedAlphaclawRuntimeWithBundled, } = require("../../lib/server/alphaclaw-runtime"); +const writeAlphaclawPackage = ({ + packageRoot, + version, + usageTrackerBody = "module.exports = 'alphaclaw';\n", +} = {}) => { + fs.mkdirSync(path.join(packageRoot, "bin"), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, "lib", "server"), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify( + { + name: "@chrysb/alphaclaw", + version, + bin: { + alphaclaw: "bin/alphaclaw.js", + }, + files: ["bin/", "lib/"], + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(packageRoot, "bin", "alphaclaw.js"), + "#!/usr/bin/env node\nconsole.log('alphaclaw');\n", + ); + fs.writeFileSync( + path.join(packageRoot, "lib", "server", "usage-tracker-config.js"), + usageTrackerBody, + ); +}; + describe("server/alphaclaw-runtime", () => { let tmpDir; @@ -116,18 +149,64 @@ describe("server/alphaclaw-runtime", () => { it("seeds the managed runtime from the bundled AlphaClaw version when missing", () => { const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); - const packageJsonPath = path.join(tmpDir, "package.json"); - fs.writeFileSync( + const bundleDir = path.join(tmpDir, "bundle"); + const packageJsonPath = path.join(bundleDir, "package.json"); + writeAlphaclawPackage({ + packageRoot: bundleDir, + version: "0.8.9", + }); + const execSyncImpl = vi.fn((command, options) => { + writeAlphaclawPackage({ + packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir: options.cwd }), + version: "0.8.9", + }); + }); + + const result = syncManagedAlphaclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + packageRoot: bundleDir, packageJsonPath, - JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.9" }), + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "0.8.9", + runtimeVersion: "0.8.9", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, ); + }); + + it("refreshes the managed runtime when bundled contents change without a version bump", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const bundleDir = path.join(tmpDir, "bundle"); + const packageJsonPath = path.join(bundleDir, "package.json"); + writeAlphaclawPackage({ + packageRoot: bundleDir, + version: "0.8.9", + usageTrackerBody: "module.exports = 'new';\n", + }); + writeAlphaclawPackage({ + packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir }), + version: "0.8.9", + usageTrackerBody: "module.exports = 'old';\n", + }); const execSyncImpl = vi.fn((command, options) => { - const pkgPath = getManagedAlphaclawPackageJsonPath({ runtimeDir: options.cwd }); - fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); - fs.writeFileSync( - pkgPath, - JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.9" }), - ); + writeAlphaclawPackage({ + packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir: options.cwd }), + version: "0.8.9", + usageTrackerBody: "module.exports = 'new';\n", + }); }); const result = syncManagedAlphaclawRuntimeWithBundled({ @@ -135,6 +214,7 @@ describe("server/alphaclaw-runtime", () => { fsModule: fs, logger: { log: vi.fn() }, runtimeDir, + packageRoot: bundleDir, packageJsonPath, }); @@ -145,7 +225,7 @@ describe("server/alphaclaw-runtime", () => { runtimeVersion: "0.8.9", }); expect(execSyncImpl).toHaveBeenCalledWith( - "npm install '@chrysb/alphaclaw@0.8.9' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, { cwd: runtimeDir, stdio: "inherit", @@ -156,17 +236,17 @@ describe("server/alphaclaw-runtime", () => { it("does not downgrade a newer managed runtime during bundled sync", () => { const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); - const packageJsonPath = path.join(tmpDir, "package.json"); + const bundleDir = path.join(tmpDir, "bundle"); + const packageJsonPath = path.join(bundleDir, "package.json"); const runtimePkgPath = getManagedAlphaclawPackageJsonPath({ runtimeDir }); - fs.writeFileSync( - packageJsonPath, - JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.8" }), - ); - fs.mkdirSync(path.dirname(runtimePkgPath), { recursive: true }); - fs.writeFileSync( - runtimePkgPath, - JSON.stringify({ name: "@chrysb/alphaclaw", version: "0.8.9" }), - ); + writeAlphaclawPackage({ + packageRoot: bundleDir, + version: "0.8.8", + }); + writeAlphaclawPackage({ + packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir }), + version: "0.8.9", + }); const execSyncImpl = vi.fn(); const result = syncManagedAlphaclawRuntimeWithBundled({ @@ -174,6 +254,7 @@ describe("server/alphaclaw-runtime", () => { fsModule: fs, logger: { log: vi.fn() }, runtimeDir, + packageRoot: bundleDir, packageJsonPath, }); diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index e8f3ec4a..689d5d26 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -5,9 +5,11 @@ const path = require("path"); const { applyManagedOpenclawPatch, ensureManagedOpenclawRuntimeProject, + getBundledOpenclawPackageRoot, getManagedOpenclawBinDir, getManagedOpenclawBinPath, getManagedOpenclawPackageJsonPath, + getManagedOpenclawPackageRoot, getManagedOpenclawRuntimeDir, installManagedOpenclawRuntime, prependManagedOpenclawBinToPath, @@ -16,6 +18,27 @@ const { syncManagedOpenclawRuntimeWithBundled, } = require("../../lib/server/openclaw-runtime"); +const writeOpenclawPackage = ({ + packageRoot, + version, + markerBody = "module.exports = 'openclaw';\n", +} = {}) => { + fs.mkdirSync(path.join(packageRoot, "lib"), { recursive: true }); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify( + { + name: "openclaw", + version, + files: ["lib/"], + }, + null, + 2, + ), + ); + fs.writeFileSync(path.join(packageRoot, "lib", "runtime.js"), markerBody); +}; + describe("server/openclaw-runtime", () => { let tmpDir; @@ -71,12 +94,11 @@ describe("server/openclaw-runtime", () => { it("reads the bundled OpenClaw version from the installed package metadata", () => { const bundleDir = path.join(tmpDir, "bundle"); - const bundledPkgPath = path.join(bundleDir, "node_modules", "openclaw", "package.json"); - fs.mkdirSync(path.dirname(bundledPkgPath), { recursive: true }); - fs.writeFileSync( - bundledPkgPath, - JSON.stringify({ name: "openclaw", version: "2026.4.6" }), - ); + const bundledPkgPath = path.join(bundleDir, "package.json"); + writeOpenclawPackage({ + packageRoot: bundleDir, + version: "2026.4.6", + }); expect( readBundledOpenclawVersion({ @@ -188,20 +210,68 @@ describe("server/openclaw-runtime", () => { it("seeds the managed runtime from the bundled OpenClaw version when missing", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); - const bundledPkgPath = path.join(bundleDir, "node_modules", "openclaw", "package.json"); - fs.mkdirSync(path.dirname(bundledPkgPath), { recursive: true }); - fs.writeFileSync( - bundledPkgPath, - JSON.stringify({ name: "openclaw", version: "2026.4.5" }), + const bundledPkgPath = path.join(bundleDir, "package.json"); + writeOpenclawPackage({ + packageRoot: bundleDir, + version: "2026.4.5", + }); + const execSyncImpl = vi.fn((command, options) => { + if (!String(command).includes("npm install")) return; + writeOpenclawPackage({ + packageRoot: getManagedOpenclawPackageRoot({ runtimeDir: options.cwd }), + version: "2026.4.5", + }); + }); + + const result = syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + resolveImpl: (request) => { + if (request === "openclaw/package.json") return bundledPkgPath; + throw new Error(`unexpected resolve ${request}`); + }, + alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "2026.4.5", + runtimeVersion: "2026.4.5", + }); + expect(execSyncImpl).toHaveBeenCalledWith( + `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, ); + }); + + it("refreshes the managed runtime when bundled contents change without a version bump", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const bundleDir = path.join(tmpDir, "bundle"); + const bundledPkgPath = path.join(bundleDir, "package.json"); + writeOpenclawPackage({ + packageRoot: bundleDir, + version: "2026.4.5", + markerBody: "module.exports = 'new';\n", + }); + writeOpenclawPackage({ + packageRoot: getManagedOpenclawPackageRoot({ runtimeDir }), + version: "2026.4.5", + markerBody: "module.exports = 'old';\n", + }); const execSyncImpl = vi.fn((command, options) => { if (!String(command).includes("npm install")) return; - const pkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir: options.cwd }); - fs.mkdirSync(path.dirname(pkgPath), { recursive: true }); - fs.writeFileSync( - pkgPath, - JSON.stringify({ name: "openclaw", version: "2026.4.5" }), - ); + writeOpenclawPackage({ + packageRoot: getManagedOpenclawPackageRoot({ runtimeDir: options.cwd }), + version: "2026.4.5", + markerBody: "module.exports = 'new';\n", + }); }); const result = syncManagedOpenclawRuntimeWithBundled({ @@ -223,7 +293,7 @@ describe("server/openclaw-runtime", () => { runtimeVersion: "2026.4.5", }); expect(execSyncImpl).toHaveBeenCalledWith( - "npm install 'openclaw@2026.4.5' --omit=dev --no-save --save=false --package-lock=false --prefer-online", + `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, { cwd: runtimeDir, stdio: "inherit", @@ -234,18 +304,16 @@ describe("server/openclaw-runtime", () => { it("does not downgrade a newer managed runtime during bundled sync", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); - const bundledPkgPath = path.join(tmpDir, "bundle", "node_modules", "openclaw", "package.json"); + const bundledPkgPath = path.join(tmpDir, "bundle", "package.json"); const runtimePkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir }); - fs.mkdirSync(path.dirname(bundledPkgPath), { recursive: true }); - fs.mkdirSync(path.dirname(runtimePkgPath), { recursive: true }); - fs.writeFileSync( - bundledPkgPath, - JSON.stringify({ name: "openclaw", version: "2026.4.1" }), - ); - fs.writeFileSync( - runtimePkgPath, - JSON.stringify({ name: "openclaw", version: "2026.4.5" }), - ); + writeOpenclawPackage({ + packageRoot: path.dirname(bundledPkgPath), + version: "2026.4.1", + }); + writeOpenclawPackage({ + packageRoot: getManagedOpenclawPackageRoot({ runtimeDir }), + version: "2026.4.5", + }); const execSyncImpl = vi.fn(); const result = syncManagedOpenclawRuntimeWithBundled({ From 7d33761f90a09b845fbf705570a10eb03f0ab718 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 08:49:29 -0700 Subject: [PATCH 11/26] Install managed runtimes from tarballs --- lib/server/alphaclaw-runtime.js | 37 ++++++++++---- lib/server/openclaw-runtime.js | 60 +++++++++++++++++----- lib/server/package-fingerprint.js | 69 ++++++++++++++++++++++++++ tests/server/alphaclaw-runtime.test.js | 25 +++++++++- tests/server/openclaw-runtime.test.js | 45 ++++++++++++++--- 5 files changed, 206 insertions(+), 30 deletions(-) diff --git a/lib/server/alphaclaw-runtime.js b/lib/server/alphaclaw-runtime.js index 1f867072..dc4cef35 100644 --- a/lib/server/alphaclaw-runtime.js +++ b/lib/server/alphaclaw-runtime.js @@ -3,7 +3,10 @@ const path = require("path"); const { kRootDir } = require("./constants"); const { compareVersionParts } = require("./helpers"); -const { computePackageFingerprint } = require("./package-fingerprint"); +const { + computePackageFingerprint, + packLocalPackageForInstall, +} = require("./package-fingerprint"); const getManagedAlphaclawRuntimeDir = ({ rootDir = kRootDir } = {}) => path.join(rootDir, ".alphaclaw-runtime"); @@ -104,14 +107,30 @@ const installManagedAlphaclawRuntime = ({ fsModule, runtimeDir, }); - execSyncImpl( - `npm install ${shellQuote(normalizedSpec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, - { - cwd: runtimeDir, - stdio: "inherit", - timeout: 180000, - }, - ); + let packedSource = null; + try { + const installTarget = normalizedSourcePath + ? (() => { + packedSource = packLocalPackageForInstall({ + execSyncImpl, + fsModule, + packageRoot: normalizedSourcePath, + tempDirPrefix: "alphaclaw-runtime-pack-", + }); + return packedSource.tarballPath; + })() + : normalizedSpec; + execSyncImpl( + `npm install ${shellQuote(installTarget)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + } finally { + packedSource?.cleanup?.(); + } return { spec: normalizedSpec, version: readManagedAlphaclawRuntimeVersion({ diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 07ce0ce3..2754f63d 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -6,14 +6,23 @@ const { compareVersionParts, normalizeOpenclawVersion, } = require("./helpers"); -const { computePackageFingerprint } = require("./package-fingerprint"); +const { + computePackageFingerprint, + packLocalPackageForInstall, + resolvePackageRootFromEntryPath, +} = require("./package-fingerprint"); const getManagedOpenclawRuntimeDir = ({ rootDir = kRootDir } = {}) => path.join(rootDir, ".openclaw-runtime"); const getBundledOpenclawPackageRoot = ({ + fsModule = fs, resolveImpl = require.resolve, -} = {}) => path.dirname(resolveImpl("openclaw/package.json")); +} = {}) => + resolvePackageRootFromEntryPath({ + fsModule, + entryPath: resolveImpl("openclaw"), + }); const getManagedOpenclawPackageRoot = ({ runtimeDir } = {}) => path.join( @@ -86,8 +95,14 @@ const readBundledOpenclawVersion = ({ resolveImpl = require.resolve, } = {}) => { try { - const pkgPath = resolveImpl("openclaw/package.json"); - const pkg = JSON.parse(fsModule.readFileSync(pkgPath, "utf8")); + const packageRoot = getBundledOpenclawPackageRoot({ + fsModule, + resolveImpl, + }); + if (!packageRoot) return null; + const pkg = JSON.parse( + fsModule.readFileSync(path.join(packageRoot, "package.json"), "utf8"), + ); return normalizeOpenclawVersion(pkg?.version || ""); } catch { return null; @@ -157,14 +172,30 @@ const installManagedOpenclawRuntime = ({ fsModule, runtimeDir, }); - execSyncImpl( - `npm install ${shellQuote(normalizedSpec)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, - { - cwd: runtimeDir, - stdio: "inherit", - timeout: 180000, - }, - ); + let packedSource = null; + try { + const installTarget = normalizedSourcePath + ? (() => { + packedSource = packLocalPackageForInstall({ + execSyncImpl, + fsModule, + packageRoot: normalizedSourcePath, + tempDirPrefix: "openclaw-runtime-pack-", + }); + return packedSource.tarballPath; + })() + : normalizedSpec; + execSyncImpl( + `npm install ${shellQuote(installTarget)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + } finally { + packedSource?.cleanup?.(); + } const installedVersion = readManagedOpenclawRuntimeVersion({ fsModule, runtimeDir, @@ -191,7 +222,10 @@ const syncManagedOpenclawRuntimeWithBundled = ({ resolveImpl, alphaclawRoot, } = {}) => { - const bundledPackageRoot = getBundledOpenclawPackageRoot({ resolveImpl }); + const bundledPackageRoot = getBundledOpenclawPackageRoot({ + fsModule, + resolveImpl, + }); const bundledVersion = readBundledOpenclawVersion({ fsModule, resolveImpl, diff --git a/lib/server/package-fingerprint.js b/lib/server/package-fingerprint.js index bb9c252f..4f4cb560 100644 --- a/lib/server/package-fingerprint.js +++ b/lib/server/package-fingerprint.js @@ -1,5 +1,6 @@ const crypto = require("crypto"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const kIgnoredDirectoryNames = new Set([".git", "node_modules"]); @@ -114,6 +115,74 @@ const computePackageFingerprint = ({ return hash.digest("hex"); }; +const resolvePackageRootFromEntryPath = ({ + fsModule = fs, + entryPath, +} = {}) => { + let cursor = path.dirname(path.resolve(String(entryPath || ""))); + while (cursor && cursor !== path.dirname(cursor)) { + if (fsModule.existsSync(path.join(cursor, "package.json"))) { + return cursor; + } + cursor = path.dirname(cursor); + } + return null; +}; + +const packLocalPackageForInstall = ({ + execSyncImpl, + fsModule = fs, + packageRoot, + tempDirPrefix = "alphaclaw-package-pack-", +} = {}) => { + const resolvedPackageRoot = path.resolve(String(packageRoot || "")); + const packDir = fsModule.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + try { + const packStdout = String( + execSyncImpl( + `npm pack ${shellQuote(resolvedPackageRoot)} --quiet --ignore-scripts --pack-destination ${shellQuote(packDir)}`, + { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + timeout: 180000, + }, + ) || "", + ) + .trim() + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + const packFileName = + packStdout.at(-1) || + fsModule.readdirSync(packDir).find((entry) => entry.endsWith(".tgz")); + if (!packFileName) { + throw new Error(`npm pack did not produce a tarball for ${resolvedPackageRoot}`); + } + const tarballPath = path.join(packDir, packFileName); + if (!fsModule.existsSync(tarballPath)) { + throw new Error(`Packed tarball missing at ${tarballPath}`); + } + return { + tarballPath, + cleanup: () => { + try { + fsModule.rmSync(packDir, { recursive: true, force: true }); + } catch {} + }, + }; + } catch (error) { + try { + fsModule.rmSync(packDir, { recursive: true, force: true }); + } catch {} + throw error; + } +}; + +const shellQuote = (value) => + `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; + module.exports = { computePackageFingerprint, + packLocalPackageForInstall, + resolvePackageRootFromEntryPath, }; diff --git a/tests/server/alphaclaw-runtime.test.js b/tests/server/alphaclaw-runtime.test.js index e32ba82a..a442ffc0 100644 --- a/tests/server/alphaclaw-runtime.test.js +++ b/tests/server/alphaclaw-runtime.test.js @@ -46,6 +46,11 @@ const writeAlphaclawPackage = ({ ); }; +const parsePackDestination = (command) => { + const match = String(command || "").match(/--pack-destination '([^']+)'/); + return match ? match[1] : ""; +}; + describe("server/alphaclaw-runtime", () => { let tmpDir; @@ -156,6 +161,13 @@ describe("server/alphaclaw-runtime", () => { version: "0.8.9", }); const execSyncImpl = vi.fn((command, options) => { + if (String(command).startsWith("npm pack ")) { + const packDestination = parsePackDestination(command); + const tarballPath = path.join(packDestination, "alphaclaw-runtime.tgz"); + fs.mkdirSync(packDestination, { recursive: true }); + fs.writeFileSync(tarballPath, "tarball"); + return "alphaclaw-runtime.tgz\n"; + } writeAlphaclawPackage({ packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir: options.cwd }), version: "0.8.9", @@ -177,8 +189,9 @@ describe("server/alphaclaw-runtime", () => { bundledVersion: "0.8.9", runtimeVersion: "0.8.9", }); + expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); expect(execSyncImpl).toHaveBeenCalledWith( - `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + expect.stringMatching(/npm install '.*alphaclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), { cwd: runtimeDir, stdio: "inherit", @@ -202,6 +215,13 @@ describe("server/alphaclaw-runtime", () => { usageTrackerBody: "module.exports = 'old';\n", }); const execSyncImpl = vi.fn((command, options) => { + if (String(command).startsWith("npm pack ")) { + const packDestination = parsePackDestination(command); + const tarballPath = path.join(packDestination, "alphaclaw-runtime.tgz"); + fs.mkdirSync(packDestination, { recursive: true }); + fs.writeFileSync(tarballPath, "tarball"); + return "alphaclaw-runtime.tgz\n"; + } writeAlphaclawPackage({ packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir: options.cwd }), version: "0.8.9", @@ -224,8 +244,9 @@ describe("server/alphaclaw-runtime", () => { bundledVersion: "0.8.9", runtimeVersion: "0.8.9", }); + expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); expect(execSyncImpl).toHaveBeenCalledWith( - `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + expect.stringMatching(/npm install '.*alphaclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), { cwd: runtimeDir, stdio: "inherit", diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index 689d5d26..44dba434 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -39,6 +39,11 @@ const writeOpenclawPackage = ({ fs.writeFileSync(path.join(packageRoot, "lib", "runtime.js"), markerBody); }; +const parsePackDestination = (command) => { + const match = String(command || "").match(/--pack-destination '([^']+)'/); + return match ? match[1] : ""; +}; + describe("server/openclaw-runtime", () => { let tmpDir; @@ -95,6 +100,9 @@ describe("server/openclaw-runtime", () => { it("reads the bundled OpenClaw version from the installed package metadata", () => { const bundleDir = path.join(tmpDir, "bundle"); const bundledPkgPath = path.join(bundleDir, "package.json"); + const bundledEntryPath = path.join(bundleDir, "dist", "index.js"); + fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); + fs.writeFileSync(bundledEntryPath, "export default {};\n"); writeOpenclawPackage({ packageRoot: bundleDir, version: "2026.4.6", @@ -104,7 +112,7 @@ describe("server/openclaw-runtime", () => { readBundledOpenclawVersion({ fsModule: fs, resolveImpl: (request) => { - if (request === "openclaw/package.json") return bundledPkgPath; + if (request === "openclaw") return bundledEntryPath; throw new Error(`unexpected resolve ${request}`); }, }), @@ -211,11 +219,21 @@ describe("server/openclaw-runtime", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); const bundledPkgPath = path.join(bundleDir, "package.json"); + const bundledEntryPath = path.join(bundleDir, "dist", "index.js"); + fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); + fs.writeFileSync(bundledEntryPath, "export default {};\n"); writeOpenclawPackage({ packageRoot: bundleDir, version: "2026.4.5", }); const execSyncImpl = vi.fn((command, options) => { + if (String(command).startsWith("npm pack ")) { + const packDestination = parsePackDestination(command); + const tarballPath = path.join(packDestination, "openclaw-runtime.tgz"); + fs.mkdirSync(packDestination, { recursive: true }); + fs.writeFileSync(tarballPath, "tarball"); + return "openclaw-runtime.tgz\n"; + } if (!String(command).includes("npm install")) return; writeOpenclawPackage({ packageRoot: getManagedOpenclawPackageRoot({ runtimeDir: options.cwd }), @@ -229,7 +247,7 @@ describe("server/openclaw-runtime", () => { logger: { log: vi.fn() }, runtimeDir, resolveImpl: (request) => { - if (request === "openclaw/package.json") return bundledPkgPath; + if (request === "openclaw") return bundledEntryPath; throw new Error(`unexpected resolve ${request}`); }, alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), @@ -241,8 +259,9 @@ describe("server/openclaw-runtime", () => { bundledVersion: "2026.4.5", runtimeVersion: "2026.4.5", }); + expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); expect(execSyncImpl).toHaveBeenCalledWith( - `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + expect.stringMatching(/npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), { cwd: runtimeDir, stdio: "inherit", @@ -255,6 +274,9 @@ describe("server/openclaw-runtime", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); const bundledPkgPath = path.join(bundleDir, "package.json"); + const bundledEntryPath = path.join(bundleDir, "dist", "index.js"); + fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); + fs.writeFileSync(bundledEntryPath, "export default {};\n"); writeOpenclawPackage({ packageRoot: bundleDir, version: "2026.4.5", @@ -266,6 +288,13 @@ describe("server/openclaw-runtime", () => { markerBody: "module.exports = 'old';\n", }); const execSyncImpl = vi.fn((command, options) => { + if (String(command).startsWith("npm pack ")) { + const packDestination = parsePackDestination(command); + const tarballPath = path.join(packDestination, "openclaw-runtime.tgz"); + fs.mkdirSync(packDestination, { recursive: true }); + fs.writeFileSync(tarballPath, "tarball"); + return "openclaw-runtime.tgz\n"; + } if (!String(command).includes("npm install")) return; writeOpenclawPackage({ packageRoot: getManagedOpenclawPackageRoot({ runtimeDir: options.cwd }), @@ -280,7 +309,7 @@ describe("server/openclaw-runtime", () => { logger: { log: vi.fn() }, runtimeDir, resolveImpl: (request) => { - if (request === "openclaw/package.json") return bundledPkgPath; + if (request === "openclaw") return bundledEntryPath; throw new Error(`unexpected resolve ${request}`); }, alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), @@ -292,8 +321,9 @@ describe("server/openclaw-runtime", () => { bundledVersion: "2026.4.5", runtimeVersion: "2026.4.5", }); + expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); expect(execSyncImpl).toHaveBeenCalledWith( - `npm install '${bundleDir}' --omit=dev --no-save --save=false --package-lock=false --prefer-online`, + expect.stringMatching(/npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), { cwd: runtimeDir, stdio: "inherit", @@ -305,7 +335,10 @@ describe("server/openclaw-runtime", () => { it("does not downgrade a newer managed runtime during bundled sync", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundledPkgPath = path.join(tmpDir, "bundle", "package.json"); + const bundledEntryPath = path.join(tmpDir, "bundle", "dist", "index.js"); const runtimePkgPath = getManagedOpenclawPackageJsonPath({ runtimeDir }); + fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); + fs.writeFileSync(bundledEntryPath, "export default {};\n"); writeOpenclawPackage({ packageRoot: path.dirname(bundledPkgPath), version: "2026.4.1", @@ -322,7 +355,7 @@ describe("server/openclaw-runtime", () => { logger: { log: vi.fn() }, runtimeDir, resolveImpl: (request) => { - if (request === "openclaw/package.json") return bundledPkgPath; + if (request === "openclaw") return bundledEntryPath; throw new Error(`unexpected resolve ${request}`); }, }); From bbb8639e58d7d4ee877993baa723f55ccc0b74af Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 09:42:55 -0700 Subject: [PATCH 12/26] Repair symlinked managed runtimes on boot --- lib/server/alphaclaw-runtime.js | 13 ++++-- lib/server/openclaw-runtime.js | 16 +++++-- lib/server/package-fingerprint.js | 14 +++++++ tests/server/alphaclaw-runtime.test.js | 52 +++++++++++++++++++++++ tests/server/openclaw-runtime.test.js | 58 ++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 7 deletions(-) diff --git a/lib/server/alphaclaw-runtime.js b/lib/server/alphaclaw-runtime.js index dc4cef35..7ae57788 100644 --- a/lib/server/alphaclaw-runtime.js +++ b/lib/server/alphaclaw-runtime.js @@ -5,6 +5,7 @@ const { kRootDir } = require("./constants"); const { compareVersionParts } = require("./helpers"); const { computePackageFingerprint, + isPackageRootSymlink, packLocalPackageForInstall, } = require("./package-fingerprint"); @@ -169,6 +170,10 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ runtimeDir, }); const runtimePackageRoot = getManagedAlphaclawPackageRoot({ runtimeDir }); + const runtimePackageRootIsSymlink = isPackageRootSymlink({ + fsModule, + packageRoot: runtimePackageRoot, + }); const bundledFingerprint = computePackageFingerprint({ fsModule, packageRoot, @@ -182,8 +187,8 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ if (runtimeVersion && compareVersionParts(runtimeVersion, bundledVersion) >= 0) { if ( compareVersionParts(runtimeVersion, bundledVersion) > 0 || - !bundledFingerprint || - runtimeFingerprint === bundledFingerprint + (!runtimePackageRootIsSymlink && + (!bundledFingerprint || runtimeFingerprint === bundledFingerprint)) ) { return { checked: true, @@ -193,7 +198,9 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ }; } logger.log( - `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} differs from bundled ${bundledVersion}; refreshing runtime...`, + runtimePackageRootIsSymlink + ? `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} is symlinked to the bundled package; refreshing runtime...` + : `[alphaclaw] Managed AlphaClaw runtime ${runtimeVersion} differs from bundled ${bundledVersion}; refreshing runtime...`, ); } else { logger.log( diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 2754f63d..afe434b7 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -8,6 +8,7 @@ const { } = require("./helpers"); const { computePackageFingerprint, + isPackageRootSymlink, packLocalPackageForInstall, resolvePackageRootFromEntryPath, } = require("./package-fingerprint"); @@ -243,20 +244,25 @@ const syncManagedOpenclawRuntimeWithBundled = ({ fsModule, runtimeDir, }); + const runtimePackageRoot = getManagedOpenclawPackageRoot({ runtimeDir }); + const runtimePackageRootIsSymlink = isPackageRootSymlink({ + fsModule, + packageRoot: runtimePackageRoot, + }); const bundledFingerprint = computePackageFingerprint({ fsModule, packageRoot: bundledPackageRoot, }); const runtimeFingerprint = computePackageFingerprint({ fsModule, - packageRoot: getManagedOpenclawPackageRoot({ runtimeDir }), + packageRoot: runtimePackageRoot, packageJsonPath: getManagedOpenclawPackageJsonPath({ runtimeDir }), }); if (runtimeVersion && compareVersionParts(runtimeVersion, bundledVersion) >= 0) { if ( compareVersionParts(runtimeVersion, bundledVersion) > 0 || - !bundledFingerprint || - runtimeFingerprint === bundledFingerprint + (!runtimePackageRootIsSymlink && + (!bundledFingerprint || runtimeFingerprint === bundledFingerprint)) ) { return { checked: true, @@ -266,7 +272,9 @@ const syncManagedOpenclawRuntimeWithBundled = ({ }; } logger.log( - `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} differs from bundled ${bundledVersion}; refreshing runtime...`, + runtimePackageRootIsSymlink + ? `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} is symlinked to the bundled package; refreshing runtime...` + : `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} differs from bundled ${bundledVersion}; refreshing runtime...`, ); } else { logger.log( diff --git a/lib/server/package-fingerprint.js b/lib/server/package-fingerprint.js index 4f4cb560..d0bd0e19 100644 --- a/lib/server/package-fingerprint.js +++ b/lib/server/package-fingerprint.js @@ -115,6 +115,19 @@ const computePackageFingerprint = ({ return hash.digest("hex"); }; +const isPackageRootSymlink = ({ + fsModule = fs, + packageRoot, +} = {}) => { + const resolvedPackageRoot = path.resolve(String(packageRoot || "")); + if (!resolvedPackageRoot || !fsModule.existsSync(resolvedPackageRoot)) return false; + try { + return fsModule.lstatSync(resolvedPackageRoot).isSymbolicLink(); + } catch { + return false; + } +}; + const resolvePackageRootFromEntryPath = ({ fsModule = fs, entryPath, @@ -183,6 +196,7 @@ const shellQuote = (value) => module.exports = { computePackageFingerprint, + isPackageRootSymlink, packLocalPackageForInstall, resolvePackageRootFromEntryPath, }; diff --git a/tests/server/alphaclaw-runtime.test.js b/tests/server/alphaclaw-runtime.test.js index a442ffc0..859ea75f 100644 --- a/tests/server/alphaclaw-runtime.test.js +++ b/tests/server/alphaclaw-runtime.test.js @@ -255,6 +255,58 @@ describe("server/alphaclaw-runtime", () => { ); }); + it("refreshes the managed runtime when the installed package root is symlinked", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const bundleDir = path.join(tmpDir, "bundle"); + const packageJsonPath = path.join(bundleDir, "package.json"); + const runtimePackageRoot = getManagedAlphaclawPackageRoot({ runtimeDir }); + writeAlphaclawPackage({ + packageRoot: bundleDir, + version: "0.8.9", + }); + fs.mkdirSync(path.dirname(runtimePackageRoot), { recursive: true }); + fs.symlinkSync(bundleDir, runtimePackageRoot, "dir"); + const execSyncImpl = vi.fn((command, options) => { + if (String(command).startsWith("npm pack ")) { + const packDestination = parsePackDestination(command); + const tarballPath = path.join(packDestination, "alphaclaw-runtime.tgz"); + fs.mkdirSync(packDestination, { recursive: true }); + fs.writeFileSync(tarballPath, "tarball"); + return "alphaclaw-runtime.tgz\n"; + } + fs.rmSync(runtimePackageRoot, { recursive: true, force: true }); + writeAlphaclawPackage({ + packageRoot: getManagedAlphaclawPackageRoot({ runtimeDir: options.cwd }), + version: "0.8.9", + }); + }); + + const result = syncManagedAlphaclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + packageRoot: bundleDir, + packageJsonPath, + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "0.8.9", + runtimeVersion: "0.8.9", + }); + expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); + expect(execSyncImpl).toHaveBeenCalledWith( + expect.stringMatching(/npm install '.*alphaclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + }); + it("does not downgrade a newer managed runtime during bundled sync", () => { const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index 44dba434..0fa36009 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -332,6 +332,64 @@ describe("server/openclaw-runtime", () => { ); }); + it("refreshes the managed runtime when the installed package root is symlinked", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const bundleDir = path.join(tmpDir, "bundle"); + const bundledEntryPath = path.join(bundleDir, "dist", "index.js"); + const runtimePackageRoot = getManagedOpenclawPackageRoot({ runtimeDir }); + fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); + fs.writeFileSync(bundledEntryPath, "export default {};\n"); + writeOpenclawPackage({ + packageRoot: bundleDir, + version: "2026.4.5", + }); + fs.mkdirSync(path.dirname(runtimePackageRoot), { recursive: true }); + fs.symlinkSync(bundleDir, runtimePackageRoot, "dir"); + const execSyncImpl = vi.fn((command, options) => { + if (String(command).startsWith("npm pack ")) { + const packDestination = parsePackDestination(command); + const tarballPath = path.join(packDestination, "openclaw-runtime.tgz"); + fs.mkdirSync(packDestination, { recursive: true }); + fs.writeFileSync(tarballPath, "tarball"); + return "openclaw-runtime.tgz\n"; + } + if (!String(command).includes("npm install")) return; + fs.rmSync(runtimePackageRoot, { recursive: true, force: true }); + writeOpenclawPackage({ + packageRoot: getManagedOpenclawPackageRoot({ runtimeDir: options.cwd }), + version: "2026.4.5", + }); + }); + + const result = syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + resolveImpl: (request) => { + if (request === "openclaw") return bundledEntryPath; + throw new Error(`unexpected resolve ${request}`); + }, + alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "2026.4.5", + runtimeVersion: "2026.4.5", + }); + expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); + expect(execSyncImpl).toHaveBeenCalledWith( + expect.stringMatching(/npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), + { + cwd: runtimeDir, + stdio: "inherit", + timeout: 180000, + }, + ); + }); + it("does not downgrade a newer managed runtime during bundled sync", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundledPkgPath = path.join(tmpDir, "bundle", "package.json"); From 77d804e52e3e6c5ef631aee529877aec306bc29e Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 10:00:21 -0700 Subject: [PATCH 13/26] Fail fast before OpenClaw runtime sync --- bin/alphaclaw.js | 74 +++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 0f467ccc..fa8af17b 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -290,36 +290,6 @@ if (fs.existsSync(pendingUpdateMarker)) { } } -const pendingOpenclawUpdateMarker = path.join(rootDir, ".openclaw-update-pending"); -const managedOpenclawRuntimeDir = getManagedOpenclawRuntimeDir({ rootDir }); -if (fs.existsSync(pendingOpenclawUpdateMarker)) { - applyPendingOpenclawUpdate({ - execSyncImpl: execSync, - fsModule: fs, - installDir: managedOpenclawRuntimeDir, - logger: console, - markerPath: pendingOpenclawUpdateMarker, - }); -} -try { - syncManagedOpenclawRuntimeWithBundled({ - execSyncImpl: execSync, - fsModule: fs, - logger: console, - runtimeDir: managedOpenclawRuntimeDir, - }); -} catch (error) { - console.log( - `[alphaclaw] Could not sync managed OpenClaw runtime from bundled install: ${error.message}`, - ); -} -prependManagedOpenclawBinToPath({ - env: process.env, - fsModule: fs, - logger: console, - runtimeDir: managedOpenclawRuntimeDir, -}); - // --------------------------------------------------------------------------- // 3. Symlink ~/.openclaw -> /.openclaw // --------------------------------------------------------------------------- @@ -626,7 +596,41 @@ if (!kSetupPassword) { } // --------------------------------------------------------------------------- -// 7. Set OPENCLAW_HOME globally so all child processes inherit it +// 7. Prepare managed OpenClaw runtime +// --------------------------------------------------------------------------- + +const pendingOpenclawUpdateMarker = path.join(rootDir, ".openclaw-update-pending"); +const managedOpenclawRuntimeDir = getManagedOpenclawRuntimeDir({ rootDir }); +if (fs.existsSync(pendingOpenclawUpdateMarker)) { + applyPendingOpenclawUpdate({ + execSyncImpl: execSync, + fsModule: fs, + installDir: managedOpenclawRuntimeDir, + logger: console, + markerPath: pendingOpenclawUpdateMarker, + }); +} +try { + syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl: execSync, + fsModule: fs, + logger: console, + runtimeDir: managedOpenclawRuntimeDir, + }); +} catch (error) { + console.log( + `[alphaclaw] Could not sync managed OpenClaw runtime from bundled install: ${error.message}`, + ); +} +prependManagedOpenclawBinToPath({ + env: process.env, + fsModule: fs, + logger: console, + runtimeDir: managedOpenclawRuntimeDir, +}); + +// --------------------------------------------------------------------------- +// 8. Set OPENCLAW_HOME globally so all child processes inherit it // --------------------------------------------------------------------------- process.env.OPENCLAW_HOME = rootDir; @@ -635,7 +639,7 @@ process.env.GOG_KEYRING_PASSWORD = process.env.GOG_KEYRING_PASSWORD || "alphaclaw"; // --------------------------------------------------------------------------- -// 8. Install gog (Google Workspace CLI) if not present +// 9. Install gog (Google Workspace CLI) if not present // --------------------------------------------------------------------------- process.env.XDG_CONFIG_HOME = openclawDir; @@ -668,7 +672,7 @@ if (!gogInstalled) { } // --------------------------------------------------------------------------- -// 9. Install/reconcile system cron entry +// 10. Install/reconcile system cron entry // --------------------------------------------------------------------------- const packagedHourlyGitSyncPath = path.join(setupDir, "hourly-git-sync.sh"); @@ -734,7 +738,7 @@ if (fs.existsSync(hourlyGitSyncPath)) { } // --------------------------------------------------------------------------- -// 9. Start cron daemon if available +// 11. Start cron daemon if available // --------------------------------------------------------------------------- try { @@ -748,7 +752,7 @@ try { } catch {} // --------------------------------------------------------------------------- -// 10. Reconcile channels if already onboarded +// 12. Reconcile channels if already onboarded // --------------------------------------------------------------------------- const configPath = path.join(openclawDir, "openclaw.json"); From 9d51694f56a69a5bf9e214ec94ef6ff83f39c877 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 10:00:44 -0700 Subject: [PATCH 14/26] 0.8.7-beta.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 884e6aeb..58c71b5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.2", + "version": "0.8.7-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.2", + "version": "0.8.7-beta.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 323de34e..8cb6adce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.2", + "version": "0.8.7-beta.3", "publishConfig": { "access": "public" }, From 5e4dcb79206d5bd9b7f26c7b83662a8970083129 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 11:15:31 -0700 Subject: [PATCH 15/26] Seed fresh runtimes from bundled node_modules --- lib/server/alphaclaw-runtime.js | 56 ++++++++++++++++++++ lib/server/openclaw-runtime.js | 69 ++++++++++++++++++++++++ lib/server/package-fingerprint.js | 72 ++++++++++++++++++++++++++ tests/server/alphaclaw-runtime.test.js | 47 +++++++++++++++++ tests/server/openclaw-runtime.test.js | 52 +++++++++++++++++++ 5 files changed, 296 insertions(+) diff --git a/lib/server/alphaclaw-runtime.js b/lib/server/alphaclaw-runtime.js index 7ae57788..c6308fa7 100644 --- a/lib/server/alphaclaw-runtime.js +++ b/lib/server/alphaclaw-runtime.js @@ -7,6 +7,7 @@ const { computePackageFingerprint, isPackageRootSymlink, packLocalPackageForInstall, + seedRuntimeFromBundledInstall, } = require("./package-fingerprint"); const getManagedAlphaclawRuntimeDir = ({ rootDir = kRootDir } = {}) => @@ -141,6 +142,37 @@ const installManagedAlphaclawRuntime = ({ }; }; +const seedManagedAlphaclawRuntimeFromBundledInstall = ({ + fsModule = fs, + logger = console, + runtimeDir, + packageRoot, +} = {}) => { + const seedResult = seedRuntimeFromBundledInstall({ + fsModule, + packageRoot, + runtimeDir, + runtimePackageJson: { + name: "alphaclaw-runtime", + private: true, + }, + }); + if (!seedResult.seeded) { + return { + seeded: false, + version: null, + }; + } + logger.log("[alphaclaw] Seeded managed AlphaClaw runtime from bundled node_modules"); + return { + seeded: true, + version: readManagedAlphaclawRuntimeVersion({ + fsModule, + runtimeDir, + }), + }; +}; + const syncManagedAlphaclawRuntimeWithBundled = ({ execSyncImpl, fsModule = fs, @@ -210,6 +242,29 @@ const syncManagedAlphaclawRuntimeWithBundled = ({ ); } + if (!runtimeVersion) { + try { + const seedResult = seedManagedAlphaclawRuntimeFromBundledInstall({ + fsModule, + logger, + runtimeDir, + packageRoot, + }); + if (seedResult.seeded) { + return { + checked: true, + synced: true, + bundledVersion, + runtimeVersion: seedResult.version || bundledVersion, + }; + } + } catch (error) { + logger.log( + `[alphaclaw] Could not seed managed AlphaClaw runtime from bundled node_modules: ${error.message}`, + ); + } + } + const installResult = installManagedAlphaclawRuntime({ execSyncImpl, fsModule, @@ -234,5 +289,6 @@ module.exports = { installManagedAlphaclawRuntime, readBundledAlphaclawVersion, readManagedAlphaclawRuntimeVersion, + seedManagedAlphaclawRuntimeFromBundledInstall, syncManagedAlphaclawRuntimeWithBundled, }; diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index afe434b7..40d47884 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -11,6 +11,7 @@ const { isPackageRootSymlink, packLocalPackageForInstall, resolvePackageRootFromEntryPath, + seedRuntimeFromBundledInstall, } = require("./package-fingerprint"); const getManagedOpenclawRuntimeDir = ({ rootDir = kRootDir } = {}) => @@ -215,6 +216,48 @@ const installManagedOpenclawRuntime = ({ }; }; +const seedManagedOpenclawRuntimeFromBundledInstall = ({ + execSyncImpl, + fsModule = fs, + logger = console, + runtimeDir, + bundledPackageRoot, + alphaclawRoot, +} = {}) => { + const seedResult = seedRuntimeFromBundledInstall({ + fsModule, + packageRoot: bundledPackageRoot, + runtimeDir, + runtimePackageJson: { + name: "alphaclaw-openclaw-runtime", + private: true, + }, + }); + if (!seedResult.seeded) { + return { + seeded: false, + version: null, + }; + } + const installedVersion = readManagedOpenclawRuntimeVersion({ + fsModule, + runtimeDir, + }); + applyManagedOpenclawPatch({ + execSyncImpl, + fsModule, + logger, + runtimeDir, + version: installedVersion, + alphaclawRoot, + }); + logger.log("[alphaclaw] Seeded managed OpenClaw runtime from bundled node_modules"); + return { + seeded: true, + version: installedVersion, + }; +}; + const syncManagedOpenclawRuntimeWithBundled = ({ execSyncImpl, fsModule = fs, @@ -284,6 +327,31 @@ const syncManagedOpenclawRuntimeWithBundled = ({ ); } + if (!runtimeVersion) { + try { + const seedResult = seedManagedOpenclawRuntimeFromBundledInstall({ + execSyncImpl, + fsModule, + logger, + runtimeDir, + bundledPackageRoot, + alphaclawRoot, + }); + if (seedResult.seeded) { + return { + checked: true, + synced: true, + bundledVersion, + runtimeVersion: seedResult.version || bundledVersion, + }; + } + } catch (error) { + logger.log( + `[alphaclaw] Could not seed managed OpenClaw runtime from bundled node_modules: ${error.message}`, + ); + } + } + const installResult = installManagedOpenclawRuntime({ execSyncImpl, fsModule, @@ -334,5 +402,6 @@ module.exports = { prependManagedOpenclawBinToPath, readBundledOpenclawVersion, readManagedOpenclawRuntimeVersion, + seedManagedOpenclawRuntimeFromBundledInstall, syncManagedOpenclawRuntimeWithBundled, }; diff --git a/lib/server/package-fingerprint.js b/lib/server/package-fingerprint.js index d0bd0e19..1da256ce 100644 --- a/lib/server/package-fingerprint.js +++ b/lib/server/package-fingerprint.js @@ -142,6 +142,76 @@ const resolvePackageRootFromEntryPath = ({ return null; }; +const resolveInstallRootFromPackageRoot = ({ packageRoot } = {}) => { + const resolvedPackageRoot = path.resolve(String(packageRoot || "")); + if (!resolvedPackageRoot) return ""; + const nodeModulesSegment = `${path.sep}node_modules${path.sep}`; + const nodeModulesIndex = resolvedPackageRoot.lastIndexOf(nodeModulesSegment); + if (nodeModulesIndex < 0) { + return resolvedPackageRoot; + } + return resolvedPackageRoot.slice(0, nodeModulesIndex); +}; + +const seedRuntimeFromBundledInstall = ({ + fsModule = fs, + packageRoot, + runtimeDir, + runtimePackageJson, +} = {}) => { + const installRoot = resolveInstallRootFromPackageRoot({ packageRoot }); + const bundledNodeModulesPath = path.join(installRoot, "node_modules"); + if (!installRoot || !fsModule.existsSync(bundledNodeModulesPath)) { + return { + seeded: false, + installRoot, + bundledNodeModulesPath, + }; + } + + const resolvedRuntimeDir = path.resolve(String(runtimeDir || "")); + const runtimeParentDir = path.dirname(resolvedRuntimeDir); + fsModule.mkdirSync(runtimeParentDir, { recursive: true }); + const tempRuntimeDir = fsModule.mkdtempSync( + path.join(runtimeParentDir, `${path.basename(resolvedRuntimeDir)}-seed-`), + ); + let seeded = false; + try { + if (runtimePackageJson) { + fsModule.writeFileSync( + path.join(tempRuntimeDir, "package.json"), + JSON.stringify(runtimePackageJson, null, 2), + ); + } + fsModule.cpSync( + bundledNodeModulesPath, + path.join(tempRuntimeDir, "node_modules"), + { + recursive: true, + dereference: true, + preserveTimestamps: true, + }, + ); + try { + fsModule.rmSync(resolvedRuntimeDir, { recursive: true, force: true }); + } catch {} + fsModule.renameSync(tempRuntimeDir, resolvedRuntimeDir); + seeded = true; + return { + seeded: true, + installRoot, + bundledNodeModulesPath, + runtimeDir: resolvedRuntimeDir, + }; + } finally { + if (!seeded) { + try { + fsModule.rmSync(tempRuntimeDir, { recursive: true, force: true }); + } catch {} + } + } +}; + const packLocalPackageForInstall = ({ execSyncImpl, fsModule = fs, @@ -198,5 +268,7 @@ module.exports = { computePackageFingerprint, isPackageRootSymlink, packLocalPackageForInstall, + resolveInstallRootFromPackageRoot, resolvePackageRootFromEntryPath, + seedRuntimeFromBundledInstall, }; diff --git a/tests/server/alphaclaw-runtime.test.js b/tests/server/alphaclaw-runtime.test.js index 859ea75f..f42ba7ee 100644 --- a/tests/server/alphaclaw-runtime.test.js +++ b/tests/server/alphaclaw-runtime.test.js @@ -200,6 +200,53 @@ describe("server/alphaclaw-runtime", () => { ); }); + it("copies the bundled node_modules tree when seeding a missing runtime from an installed app", () => { + const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); + const installRoot = path.join(tmpDir, "install"); + const bundleDir = path.join( + installRoot, + "node_modules", + "@chrysb", + "alphaclaw", + ); + const packageJsonPath = path.join(bundleDir, "package.json"); + writeAlphaclawPackage({ + packageRoot: bundleDir, + version: "0.8.9", + }); + fs.mkdirSync(path.join(installRoot, "node_modules", "openclaw"), { + recursive: true, + }); + fs.writeFileSync( + path.join(installRoot, "node_modules", "openclaw", "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.1" }), + ); + const execSyncImpl = vi.fn(); + + const result = syncManagedAlphaclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + packageRoot: bundleDir, + packageJsonPath, + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "0.8.9", + runtimeVersion: "0.8.9", + }); + expect(execSyncImpl).not.toHaveBeenCalled(); + expect(fs.existsSync(getManagedAlphaclawCliPath({ runtimeDir }))).toBe(true); + expect( + fs.existsSync( + path.join(runtimeDir, "node_modules", "openclaw", "package.json"), + ), + ).toBe(true); + }); + it("refreshes the managed runtime when bundled contents change without a version bump", () => { const runtimeDir = getManagedAlphaclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index 0fa36009..fc34261a 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -270,6 +270,58 @@ describe("server/openclaw-runtime", () => { ); }); + it("copies the bundled node_modules tree when seeding a missing runtime from an installed app", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const installRoot = path.join(tmpDir, "install"); + const bundleDir = path.join(installRoot, "node_modules", "openclaw"); + const bundledEntryPath = path.join(bundleDir, "dist", "index.js"); + fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); + fs.writeFileSync(bundledEntryPath, "export default {};\n"); + writeOpenclawPackage({ + packageRoot: bundleDir, + version: "2026.4.5", + }); + fs.mkdirSync(path.join(installRoot, "node_modules", ".bin"), { + recursive: true, + }); + fs.writeFileSync( + path.join(installRoot, "node_modules", ".bin", "openclaw"), + "#!/usr/bin/env node\nconsole.log('openclaw');\n", + ); + fs.mkdirSync(path.join(installRoot, "node_modules", "zod"), { + recursive: true, + }); + fs.writeFileSync( + path.join(installRoot, "node_modules", "zod", "package.json"), + JSON.stringify({ name: "zod", version: "3.0.0" }), + ); + const execSyncImpl = vi.fn(); + + const result = syncManagedOpenclawRuntimeWithBundled({ + execSyncImpl, + fsModule: fs, + logger: { log: vi.fn() }, + runtimeDir, + resolveImpl: (request) => { + if (request === "openclaw") return bundledEntryPath; + throw new Error(`unexpected resolve ${request}`); + }, + alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), + }); + + expect(result).toEqual({ + checked: true, + synced: true, + bundledVersion: "2026.4.5", + runtimeVersion: "2026.4.5", + }); + expect(execSyncImpl).not.toHaveBeenCalled(); + expect(fs.existsSync(getManagedOpenclawBinPath({ runtimeDir }))).toBe(true); + expect( + fs.existsSync(path.join(runtimeDir, "node_modules", "zod", "package.json")), + ).toBe(true); + }); + it("refreshes the managed runtime when bundled contents change without a version bump", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); From 859239f11f2c3cc95619e542d3d859bf52c2d150 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 11:22:00 -0700 Subject: [PATCH 16/26] Swap managed runtimes after pending updates --- lib/server/pending-alphaclaw-update.js | 17 +++++- lib/server/pending-openclaw-update.js | 17 +++++- tests/server/pending-alphaclaw-update.test.js | 53 +++++++++++++++---- tests/server/pending-openclaw-update.test.js | 53 +++++++++++++++---- 4 files changed, 120 insertions(+), 20 deletions(-) diff --git a/lib/server/pending-alphaclaw-update.js b/lib/server/pending-alphaclaw-update.js index a95b0850..8e646513 100644 --- a/lib/server/pending-alphaclaw-update.js +++ b/lib/server/pending-alphaclaw-update.js @@ -1,3 +1,5 @@ +const path = require("path"); + const { installManagedAlphaclawRuntime, } = require("./alphaclaw-runtime"); @@ -36,13 +38,23 @@ const applyPendingAlphaclawUpdate = ({ const spec = buildPendingAlphaclawInstallSpec(marker); logger.log(`[alphaclaw] Pending update detected, installing ${spec}...`); + const resolvedInstallDir = path.resolve(String(installDir || "")); + const installParentDir = path.dirname(resolvedInstallDir); + const tempInstallDir = fsModule.mkdtempSync( + path.join(installParentDir, `${path.basename(resolvedInstallDir)}-pending-`), + ); + try { installManagedAlphaclawRuntime({ execSyncImpl, fsModule, - runtimeDir: installDir, + runtimeDir: tempInstallDir, spec, }); + try { + fsModule.rmSync(resolvedInstallDir, { recursive: true, force: true }); + } catch {} + fsModule.renameSync(tempInstallDir, resolvedInstallDir); fsModule.unlinkSync(markerPath); logger.log("[alphaclaw] Update applied successfully"); return { @@ -52,6 +64,9 @@ const applyPendingAlphaclawUpdate = ({ }; } catch (error) { logger.log(`[alphaclaw] Update install failed: ${error.message}`); + try { + fsModule.rmSync(tempInstallDir, { recursive: true, force: true }); + } catch {} try { fsModule.unlinkSync(markerPath); } catch {} diff --git a/lib/server/pending-openclaw-update.js b/lib/server/pending-openclaw-update.js index 2823ca64..15296147 100644 --- a/lib/server/pending-openclaw-update.js +++ b/lib/server/pending-openclaw-update.js @@ -1,3 +1,5 @@ +const path = require("path"); + const { installManagedOpenclawRuntime, } = require("./openclaw-runtime"); @@ -36,14 +38,24 @@ const applyPendingOpenclawUpdate = ({ const spec = buildPendingOpenclawInstallSpec(marker); logger.log(`[alphaclaw] Pending OpenClaw update detected, installing ${spec}...`); + const resolvedInstallDir = path.resolve(String(installDir || "")); + const installParentDir = path.dirname(resolvedInstallDir); + const tempInstallDir = fsModule.mkdtempSync( + path.join(installParentDir, `${path.basename(resolvedInstallDir)}-pending-`), + ); + try { installManagedOpenclawRuntime({ execSyncImpl, fsModule, logger, - runtimeDir: installDir, + runtimeDir: tempInstallDir, spec, }); + try { + fsModule.rmSync(resolvedInstallDir, { recursive: true, force: true }); + } catch {} + fsModule.renameSync(tempInstallDir, resolvedInstallDir); fsModule.unlinkSync(markerPath); logger.log("[alphaclaw] OpenClaw update applied successfully"); return { @@ -53,6 +65,9 @@ const applyPendingOpenclawUpdate = ({ }; } catch (error) { logger.log(`[alphaclaw] OpenClaw update install failed: ${error.message}`); + try { + fsModule.rmSync(tempInstallDir, { recursive: true, force: true }); + } catch {} try { fsModule.unlinkSync(markerPath); } catch {} diff --git a/tests/server/pending-alphaclaw-update.test.js b/tests/server/pending-alphaclaw-update.test.js index 1c6cb3bd..94628a61 100644 --- a/tests/server/pending-alphaclaw-update.test.js +++ b/tests/server/pending-alphaclaw-update.test.js @@ -42,6 +42,7 @@ describe("server/pending-alphaclaw-update", () => { }); it("installs the pending update with a real npm install command and clears the marker", () => { + const runtimeDir = path.join(tmpDir, ".alphaclaw-runtime"); const markerPath = path.join(tmpDir, ".alphaclaw-update-pending"); fs.writeFileSync( markerPath, @@ -57,7 +58,7 @@ describe("server/pending-alphaclaw-update", () => { const result = applyPendingAlphaclawUpdate({ execSyncImpl, fsModule: fs, - installDir: tmpDir, + installDir: runtimeDir, logger: { log: vi.fn() }, markerPath, }); @@ -67,16 +68,17 @@ describe("server/pending-alphaclaw-update", () => { installed: true, spec: "@chrysb/alphaclaw@0.8.6", }); - expect(execSyncImpl).toHaveBeenCalledWith( + expect(execSyncImpl).toHaveBeenCalledTimes(1); + expect(execSyncImpl.mock.calls[0][0]).toBe( "npm install '@chrysb/alphaclaw@0.8.6' --omit=dev --no-save --save=false --package-lock=false --prefer-online", - { - cwd: tmpDir, - stdio: "inherit", - timeout: 180000, - }, ); + expect(execSyncImpl.mock.calls[0][1]).toEqual({ + cwd: expect.stringMatching(new RegExp(`^${runtimeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-pending-[^/]+$`)), + stdio: "inherit", + timeout: 180000, + }); expect( - JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf8")), + JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), ).toEqual({ name: "alphaclaw-runtime", private: true, @@ -84,7 +86,40 @@ describe("server/pending-alphaclaw-update", () => { expect(fs.existsSync(markerPath)).toBe(false); }); + it("keeps the existing runtime in place when the pending install fails", () => { + const runtimeDir = path.join(tmpDir, ".alphaclaw-runtime"); + const markerPath = path.join(tmpDir, ".alphaclaw-update-pending"); + fs.writeFileSync(markerPath, "{not-json"); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync( + path.join(runtimeDir, "package.json"), + JSON.stringify({ name: "existing-runtime", private: true }), + ); + const execSyncImpl = vi.fn(() => { + throw new Error("boom"); + }); + + const result = applyPendingAlphaclawUpdate({ + execSyncImpl, + fsModule: fs, + installDir: runtimeDir, + logger: { log: vi.fn() }, + markerPath, + }); + + expect(result.attempted).toBe(true); + expect(result.installed).toBe(false); + expect( + JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), + ).toEqual({ + name: "existing-runtime", + private: true, + }); + expect(fs.existsSync(markerPath)).toBe(false); + }); + it("removes the marker and reports failure when npm install throws", () => { + const runtimeDir = path.join(tmpDir, ".alphaclaw-runtime"); const markerPath = path.join(tmpDir, ".alphaclaw-update-pending"); fs.writeFileSync(markerPath, "{not-json"); const execSyncImpl = vi.fn(() => { @@ -94,7 +129,7 @@ describe("server/pending-alphaclaw-update", () => { const result = applyPendingAlphaclawUpdate({ execSyncImpl, fsModule: fs, - installDir: tmpDir, + installDir: runtimeDir, logger: { log: vi.fn() }, markerPath, }); diff --git a/tests/server/pending-openclaw-update.test.js b/tests/server/pending-openclaw-update.test.js index 0bd38673..478d8a5d 100644 --- a/tests/server/pending-openclaw-update.test.js +++ b/tests/server/pending-openclaw-update.test.js @@ -40,6 +40,7 @@ describe("server/pending-openclaw-update", () => { }); it("installs the pending update with a real npm install command and clears the marker", () => { + const runtimeDir = path.join(tmpDir, ".openclaw-runtime"); const markerPath = path.join(tmpDir, ".openclaw-update-pending"); fs.writeFileSync( markerPath, @@ -55,7 +56,7 @@ describe("server/pending-openclaw-update", () => { const result = applyPendingOpenclawUpdate({ execSyncImpl, fsModule: fs, - installDir: tmpDir, + installDir: runtimeDir, logger: { log: vi.fn() }, markerPath, }); @@ -65,16 +66,17 @@ describe("server/pending-openclaw-update", () => { installed: true, spec: "openclaw@1.1.0", }); - expect(execSyncImpl).toHaveBeenCalledWith( + expect(execSyncImpl).toHaveBeenCalledTimes(1); + expect(execSyncImpl.mock.calls[0][0]).toBe( "npm install 'openclaw@1.1.0' --omit=dev --no-save --save=false --package-lock=false --prefer-online", - { - cwd: tmpDir, - stdio: "inherit", - timeout: 180000, - }, ); + expect(execSyncImpl.mock.calls[0][1]).toEqual({ + cwd: expect.stringMatching(new RegExp(`^${runtimeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-pending-[^/]+$`)), + stdio: "inherit", + timeout: 180000, + }); expect( - JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf8")), + JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), ).toEqual({ name: "alphaclaw-openclaw-runtime", private: true, @@ -82,7 +84,40 @@ describe("server/pending-openclaw-update", () => { expect(fs.existsSync(markerPath)).toBe(false); }); + it("keeps the existing runtime in place when the pending install fails", () => { + const runtimeDir = path.join(tmpDir, ".openclaw-runtime"); + const markerPath = path.join(tmpDir, ".openclaw-update-pending"); + fs.writeFileSync(markerPath, "{not-json"); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync( + path.join(runtimeDir, "package.json"), + JSON.stringify({ name: "existing-runtime", private: true }), + ); + const execSyncImpl = vi.fn(() => { + throw new Error("boom"); + }); + + const result = applyPendingOpenclawUpdate({ + execSyncImpl, + fsModule: fs, + installDir: runtimeDir, + logger: { log: vi.fn() }, + markerPath, + }); + + expect(result.attempted).toBe(true); + expect(result.installed).toBe(false); + expect( + JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), + ).toEqual({ + name: "existing-runtime", + private: true, + }); + expect(fs.existsSync(markerPath)).toBe(false); + }); + it("removes the marker and reports failure when npm install throws", () => { + const runtimeDir = path.join(tmpDir, ".openclaw-runtime"); const markerPath = path.join(tmpDir, ".openclaw-update-pending"); fs.writeFileSync(markerPath, "{not-json"); const execSyncImpl = vi.fn(() => { @@ -92,7 +127,7 @@ describe("server/pending-openclaw-update", () => { const result = applyPendingOpenclawUpdate({ execSyncImpl, fsModule: fs, - installDir: tmpDir, + installDir: runtimeDir, logger: { log: vi.fn() }, markerPath, }); From 0bbf064b9e9ce0b21afabf3a97bd9d62eb2d06cc Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 11:22:24 -0700 Subject: [PATCH 17/26] 0.8.7-beta.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58c71b5d..0e645a37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.3", + "version": "0.8.7-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.3", + "version": "0.8.7-beta.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8cb6adce..6e312d99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.3", + "version": "0.8.7-beta.4", "publishConfig": { "access": "public" }, From 8ce1cae8ce7e1cd279dab8f954089276c0d3cf2a Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 12:38:09 -0700 Subject: [PATCH 18/26] Isolate managed OpenClaw runtime usage --- lib/server/gateway.js | 46 +++++++++++------ lib/server/openclaw-runtime.js | 71 +-------------------------- tests/server/gateway.test.js | 18 ++++++- tests/server/openclaw-runtime.test.js | 52 -------------------- 4 files changed, 49 insertions(+), 138 deletions(-) diff --git a/lib/server/gateway.js b/lib/server/gateway.js index f1965e79..4151ccfa 100644 --- a/lib/server/gateway.js +++ b/lib/server/gateway.js @@ -2,6 +2,7 @@ const path = require("path"); const { spawn, execSync } = require("child_process"); const fs = require("fs"); const net = require("net"); +const { getManagedOpenclawBinPath } = require("./openclaw-runtime"); const { ALPHACLAW_DIR, OPENCLAW_DIR, @@ -48,6 +49,16 @@ const gatewayEnv = () => ({ XDG_CONFIG_HOME: OPENCLAW_DIR, }); +const shellQuote = (value) => + `'${String(value || "").replace(/'/g, `'\"'\"'`)}'`; + +const getOpenclawCommandPath = () => { + const managedBinPath = getManagedOpenclawBinPath(); + return fs.existsSync(managedBinPath) ? managedBinPath : "openclaw"; +}; + +const getOpenclawCommandPrefix = () => shellQuote(getOpenclawCommandPath()); + const writeOnboardingMarker = (reason) => { fs.mkdirSync(ALPHACLAW_DIR, { recursive: true }); fs.writeFileSync( @@ -119,9 +130,10 @@ const isGatewayRunning = () => }); const runGatewayCmd = (cmd) => { - console.log(`[alphaclaw] Running: openclaw gateway ${cmd}`); + const openclawCommandPrefix = getOpenclawCommandPrefix(); + console.log(`[alphaclaw] Running: ${openclawCommandPrefix} gateway ${cmd}`); try { - const out = execSync(`openclaw gateway ${cmd}`, { + const out = execSync(`${openclawCommandPrefix} gateway ${cmd}`, { env: gatewayEnv(), timeout: 15000, encoding: "utf8", @@ -146,7 +158,7 @@ const launchGatewayProcess = () => { return gatewayChild; } gatewayStderrTail = []; - const child = spawn("openclaw", ["gateway", "run"], { + const child = spawn(getOpenclawCommandPath(), ["gateway", "run"], { env: gatewayEnv(), stdio: ["pipe", "pipe", "pipe"], }); @@ -306,7 +318,7 @@ const syncChannelConfig = (savedVars, mode = "all") => { const appToken = savedMap[def.extraEnvKeys?.[0]]; if (!appToken) continue; execSync( - `openclaw channels add --channel slack --bot-token "${token}" --app-token "${appToken}"`, + `${getOpenclawCommandPrefix()} channels add --channel slack --bot-token "${token}" --app-token "${appToken}"`, { env, timeout: 15000, encoding: "utf8" }, ); let raw = fs.readFileSync(configPath, "utf8"); @@ -318,11 +330,14 @@ const syncChannelConfig = (savedVars, mode = "all") => { } fs.writeFileSync(configPath, raw); } else { - execSync(`openclaw channels add --channel ${ch} --token "${token}"`, { - env, - timeout: 15000, - encoding: "utf8", - }); + execSync( + `${getOpenclawCommandPrefix()} channels add --channel ${ch} --token "${token}"`, + { + env, + timeout: 15000, + encoding: "utf8", + }, + ); const raw = fs.readFileSync(configPath, "utf8"); if (raw.includes(token)) { fs.writeFileSync( @@ -344,11 +359,14 @@ const syncChannelConfig = (savedVars, mode = "all") => { ) { console.log(`[alphaclaw] Removing channel: ${ch}`); try { - execSync(`openclaw channels remove --channel ${ch} --delete`, { - env, - timeout: 15000, - encoding: "utf8", - }); + execSync( + `${getOpenclawCommandPrefix()} channels remove --channel ${ch} --delete`, + { + env, + timeout: 15000, + encoding: "utf8", + }, + ); console.log(`[alphaclaw] Channel ${ch} removed`); } catch (e) { console.error( diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 40d47884..944272b3 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -11,7 +11,6 @@ const { isPackageRootSymlink, packLocalPackageForInstall, resolvePackageRootFromEntryPath, - seedRuntimeFromBundledInstall, } = require("./package-fingerprint"); const getManagedOpenclawRuntimeDir = ({ rootDir = kRootDir } = {}) => @@ -216,48 +215,6 @@ const installManagedOpenclawRuntime = ({ }; }; -const seedManagedOpenclawRuntimeFromBundledInstall = ({ - execSyncImpl, - fsModule = fs, - logger = console, - runtimeDir, - bundledPackageRoot, - alphaclawRoot, -} = {}) => { - const seedResult = seedRuntimeFromBundledInstall({ - fsModule, - packageRoot: bundledPackageRoot, - runtimeDir, - runtimePackageJson: { - name: "alphaclaw-openclaw-runtime", - private: true, - }, - }); - if (!seedResult.seeded) { - return { - seeded: false, - version: null, - }; - } - const installedVersion = readManagedOpenclawRuntimeVersion({ - fsModule, - runtimeDir, - }); - applyManagedOpenclawPatch({ - execSyncImpl, - fsModule, - logger, - runtimeDir, - version: installedVersion, - alphaclawRoot, - }); - logger.log("[alphaclaw] Seeded managed OpenClaw runtime from bundled node_modules"); - return { - seeded: true, - version: installedVersion, - }; -}; - const syncManagedOpenclawRuntimeWithBundled = ({ execSyncImpl, fsModule = fs, @@ -323,35 +280,10 @@ const syncManagedOpenclawRuntimeWithBundled = ({ logger.log( runtimeVersion ? `[alphaclaw] Managed OpenClaw runtime ${runtimeVersion} is older than bundled ${bundledVersion}; syncing runtime...` - : `[alphaclaw] Managed OpenClaw runtime missing; seeding bundled OpenClaw ${bundledVersion}...`, + : `[alphaclaw] Managed OpenClaw runtime missing; installing bundled OpenClaw ${bundledVersion}...`, ); } - if (!runtimeVersion) { - try { - const seedResult = seedManagedOpenclawRuntimeFromBundledInstall({ - execSyncImpl, - fsModule, - logger, - runtimeDir, - bundledPackageRoot, - alphaclawRoot, - }); - if (seedResult.seeded) { - return { - checked: true, - synced: true, - bundledVersion, - runtimeVersion: seedResult.version || bundledVersion, - }; - } - } catch (error) { - logger.log( - `[alphaclaw] Could not seed managed OpenClaw runtime from bundled node_modules: ${error.message}`, - ); - } - } - const installResult = installManagedOpenclawRuntime({ execSyncImpl, fsModule, @@ -402,6 +334,5 @@ module.exports = { prependManagedOpenclawBinToPath, readBundledOpenclawVersion, readManagedOpenclawRuntimeVersion, - seedManagedOpenclawRuntimeFromBundledInstall, syncManagedOpenclawRuntimeWithBundled, }; diff --git a/tests/server/gateway.test.js b/tests/server/gateway.test.js index 5c412ce7..99efb844 100644 --- a/tests/server/gateway.test.js +++ b/tests/server/gateway.test.js @@ -2,6 +2,7 @@ const childProcess = require("child_process"); const fs = require("fs"); const net = require("net"); const path = require("path"); +const { getManagedOpenclawBinPath } = require("../../lib/server/openclaw-runtime"); const { ALPHACLAW_DIR, kOnboardingMarkerPath, @@ -9,6 +10,7 @@ const { } = require("../../lib/server/constants"); const kLegacyControlUiSkillPath = path.join(OPENCLAW_DIR, "skills", "control-ui", "SKILL.md"); +const kManagedOpenclawBinPath = getManagedOpenclawBinPath(); const modulePath = require.resolve("../../lib/server/gateway"); const originalSpawn = childProcess.spawn; @@ -80,13 +82,23 @@ describe("server/gateway restart behavior", () => { await gateway.startGateway(); expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith( + kManagedOpenclawBinPath, + ["gateway", "run"], + expect.objectContaining({ + env: expect.any(Object), + stdio: ["pipe", "pipe", "pipe"], + }), + ); const reloadEnv = vi.fn(); gateway.restartGateway(reloadEnv); expect(reloadEnv).toHaveBeenCalledTimes(1); expect(execSyncMock).toHaveBeenCalledTimes(1); - expect(execSyncMock).toHaveBeenCalledWith("openclaw gateway --force", { + expect(execSyncMock.mock.calls[0][0]).toContain(kManagedOpenclawBinPath); + expect(execSyncMock.mock.calls[0][0]).toContain(" gateway --force"); + expect(execSyncMock.mock.calls[0][1]).toEqual({ env: expect.any(Object), timeout: 15000, encoding: "utf8", @@ -122,7 +134,9 @@ describe("server/gateway restart behavior", () => { expect(reloadEnv).toHaveBeenCalledTimes(1); expect(execSyncMock).toHaveBeenCalledTimes(1); - expect(execSyncMock).toHaveBeenCalledWith("openclaw gateway --force", { + expect(execSyncMock.mock.calls[0][0]).toContain(kManagedOpenclawBinPath); + expect(execSyncMock.mock.calls[0][0]).toContain(" gateway --force"); + expect(execSyncMock.mock.calls[0][1]).toEqual({ env: expect.any(Object), timeout: 15000, encoding: "utf8", diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index fc34261a..0fa36009 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -270,58 +270,6 @@ describe("server/openclaw-runtime", () => { ); }); - it("copies the bundled node_modules tree when seeding a missing runtime from an installed app", () => { - const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); - const installRoot = path.join(tmpDir, "install"); - const bundleDir = path.join(installRoot, "node_modules", "openclaw"); - const bundledEntryPath = path.join(bundleDir, "dist", "index.js"); - fs.mkdirSync(path.dirname(bundledEntryPath), { recursive: true }); - fs.writeFileSync(bundledEntryPath, "export default {};\n"); - writeOpenclawPackage({ - packageRoot: bundleDir, - version: "2026.4.5", - }); - fs.mkdirSync(path.join(installRoot, "node_modules", ".bin"), { - recursive: true, - }); - fs.writeFileSync( - path.join(installRoot, "node_modules", ".bin", "openclaw"), - "#!/usr/bin/env node\nconsole.log('openclaw');\n", - ); - fs.mkdirSync(path.join(installRoot, "node_modules", "zod"), { - recursive: true, - }); - fs.writeFileSync( - path.join(installRoot, "node_modules", "zod", "package.json"), - JSON.stringify({ name: "zod", version: "3.0.0" }), - ); - const execSyncImpl = vi.fn(); - - const result = syncManagedOpenclawRuntimeWithBundled({ - execSyncImpl, - fsModule: fs, - logger: { log: vi.fn() }, - runtimeDir, - resolveImpl: (request) => { - if (request === "openclaw") return bundledEntryPath; - throw new Error(`unexpected resolve ${request}`); - }, - alphaclawRoot: path.join(tmpDir, "alphaclaw-no-patches"), - }); - - expect(result).toEqual({ - checked: true, - synced: true, - bundledVersion: "2026.4.5", - runtimeVersion: "2026.4.5", - }); - expect(execSyncImpl).not.toHaveBeenCalled(); - expect(fs.existsSync(getManagedOpenclawBinPath({ runtimeDir }))).toBe(true); - expect( - fs.existsSync(path.join(runtimeDir, "node_modules", "zod", "package.json")), - ).toBe(true); - }); - it("refreshes the managed runtime when bundled contents change without a version bump", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); From 2970247e194969299a3e39cdcfbf6ecfb9869bd4 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 12:39:09 -0700 Subject: [PATCH 19/26] 0.8.7-beta.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e645a37..ba113830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.4", + "version": "0.8.7-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.4", + "version": "0.8.7-beta.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6e312d99..e7fb8f05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.4", + "version": "0.8.7-beta.5", "publishConfig": { "access": "public" }, From 86fea4c4d2d6b3c0d8d65f2572b8ec5c8ef2a90b Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 12:58:47 -0700 Subject: [PATCH 20/26] Defer OpenClaw bundled plugin postinstall --- lib/server/openclaw-runtime.js | 54 +++++++++ tests/server/openclaw-runtime.test.js | 114 ++++++++++++++++--- tests/server/pending-openclaw-update.test.js | 15 ++- 3 files changed, 164 insertions(+), 19 deletions(-) diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 944272b3..4c1a659b 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -156,6 +156,49 @@ const applyManagedOpenclawPatch = ({ return true; }; +const kDisableBundledPluginPostinstallEnv = + "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; +const kBundledPluginPostinstallFailureMarker = + "[postinstall] could not install bundled plugin deps:"; + +const runManagedOpenclawBundledPluginPostinstall = ({ + execSyncImpl, + fsModule = fs, + logger = console, + runtimeDir, +} = {}) => { + const packageRoot = getManagedOpenclawPackageRoot({ runtimeDir }); + const postinstallScriptPath = path.join( + packageRoot, + "scripts", + "postinstall-bundled-plugins.mjs", + ); + if (!fsModule.existsSync(postinstallScriptPath)) { + return false; + } + const env = { ...process.env }; + delete env[kDisableBundledPluginPostinstallEnv]; + const output = String( + execSyncImpl( + `${shellQuote(process.execPath)} ${shellQuote(postinstallScriptPath)} 2>&1`, + { + cwd: packageRoot, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 180000, + }, + ) || "", + ).trim(); + if (output) { + logger.log(output); + } + if (output.includes(kBundledPluginPostinstallFailureMarker)) { + throw new Error(output); + } + return true; +}; + const installManagedOpenclawRuntime = ({ execSyncImpl, fsModule = fs, @@ -190,6 +233,10 @@ const installManagedOpenclawRuntime = ({ `npm install ${shellQuote(installTarget)} --omit=dev --no-save --save=false --package-lock=false --prefer-online`, { cwd: runtimeDir, + env: { + ...process.env, + [kDisableBundledPluginPostinstallEnv]: "1", + }, stdio: "inherit", timeout: 180000, }, @@ -197,6 +244,12 @@ const installManagedOpenclawRuntime = ({ } finally { packedSource?.cleanup?.(); } + runManagedOpenclawBundledPluginPostinstall({ + execSyncImpl, + fsModule, + logger, + runtimeDir, + }); const installedVersion = readManagedOpenclawRuntimeVersion({ fsModule, runtimeDir, @@ -334,5 +387,6 @@ module.exports = { prependManagedOpenclawBinToPath, readBundledOpenclawVersion, readManagedOpenclawRuntimeVersion, + runManagedOpenclawBundledPluginPostinstall, syncManagedOpenclawRuntimeWithBundled, }; diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index 0fa36009..e9337600 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -15,6 +15,7 @@ const { prependManagedOpenclawBinToPath, readBundledOpenclawVersion, readManagedOpenclawRuntimeVersion, + runManagedOpenclawBundledPluginPostinstall, syncManagedOpenclawRuntimeWithBundled, } = require("../../lib/server/openclaw-runtime"); @@ -204,17 +205,87 @@ describe("server/openclaw-runtime", () => { }); expect(execSyncImpl).toHaveBeenCalledWith( "npm install 'openclaw@2026.4.1' --omit=dev --no-save --save=false --package-lock=false --prefer-online", - { + expect.objectContaining({ cwd: runtimeDir, + env: expect.objectContaining({ + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + }), stdio: "inherit", timeout: 180000, - }, + }), ); expect(execSyncImpl.mock.calls.some(([command]) => String(command).includes("patch-package"))).toBe( true, ); }); + it("runs bundled plugin postinstall after the parent install completes", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const packageRoot = getManagedOpenclawPackageRoot({ runtimeDir }); + const postinstallScriptPath = path.join( + packageRoot, + "scripts", + "postinstall-bundled-plugins.mjs", + ); + fs.mkdirSync(path.dirname(postinstallScriptPath), { recursive: true }); + fs.writeFileSync(postinstallScriptPath, "console.log('postinstall');\n"); + const execSyncImpl = vi.fn(); + const logger = { log: vi.fn() }; + + const ran = runManagedOpenclawBundledPluginPostinstall({ + execSyncImpl, + fsModule: fs, + logger, + runtimeDir, + }); + + expect(ran).toBe(true); + expect(execSyncImpl).toHaveBeenCalledTimes(1); + expect(String(execSyncImpl.mock.calls[0][0])).toContain(process.execPath); + expect(String(execSyncImpl.mock.calls[0][0])).toContain(postinstallScriptPath); + expect(execSyncImpl.mock.calls[0][1]).toEqual( + expect.objectContaining({ + cwd: packageRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 180000, + }), + ); + expect(execSyncImpl.mock.calls[0][1].env).not.toHaveProperty( + "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL", + ); + expect(logger.log).not.toHaveBeenCalled(); + }); + + it("throws when bundled plugin postinstall reports a runtime-deps install failure", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const packageRoot = getManagedOpenclawPackageRoot({ runtimeDir }); + const postinstallScriptPath = path.join( + packageRoot, + "scripts", + "postinstall-bundled-plugins.mjs", + ); + fs.mkdirSync(path.dirname(postinstallScriptPath), { recursive: true }); + fs.writeFileSync(postinstallScriptPath, "console.log('postinstall');\n"); + const execSyncImpl = vi.fn( + () => + "[postinstall] could not install bundled plugin deps: Error: npm error ENOSPC", + ); + const logger = { log: vi.fn() }; + + expect(() => + runManagedOpenclawBundledPluginPostinstall({ + execSyncImpl, + fsModule: fs, + logger, + runtimeDir, + }), + ).toThrow(/could not install bundled plugin deps/); + expect(logger.log).toHaveBeenCalledWith( + "[postinstall] could not install bundled plugin deps: Error: npm error ENOSPC", + ); + }); + it("seeds the managed runtime from the bundled OpenClaw version when missing", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); @@ -260,13 +331,18 @@ describe("server/openclaw-runtime", () => { runtimeVersion: "2026.4.5", }); expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); - expect(execSyncImpl).toHaveBeenCalledWith( - expect.stringMatching(/npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), - { + expect(String(execSyncImpl.mock.calls[1][0])).toMatch( + /npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/, + ); + expect(execSyncImpl.mock.calls[1][1]).toEqual( + expect.objectContaining({ cwd: runtimeDir, + env: expect.objectContaining({ + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + }), stdio: "inherit", timeout: 180000, - }, + }), ); }); @@ -322,13 +398,18 @@ describe("server/openclaw-runtime", () => { runtimeVersion: "2026.4.5", }); expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); - expect(execSyncImpl).toHaveBeenCalledWith( - expect.stringMatching(/npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), - { + expect(String(execSyncImpl.mock.calls[1][0])).toMatch( + /npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/, + ); + expect(execSyncImpl.mock.calls[1][1]).toEqual( + expect.objectContaining({ cwd: runtimeDir, + env: expect.objectContaining({ + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + }), stdio: "inherit", timeout: 180000, - }, + }), ); }); @@ -380,13 +461,18 @@ describe("server/openclaw-runtime", () => { runtimeVersion: "2026.4.5", }); expect(execSyncImpl.mock.calls[0][0]).toContain(`npm pack '${bundleDir}'`); - expect(execSyncImpl).toHaveBeenCalledWith( - expect.stringMatching(/npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/), - { + expect(String(execSyncImpl.mock.calls[1][0])).toMatch( + /npm install '.*openclaw-runtime\.tgz' --omit=dev --no-save --save=false --package-lock=false --prefer-online/, + ); + expect(execSyncImpl.mock.calls[1][1]).toEqual( + expect.objectContaining({ cwd: runtimeDir, + env: expect.objectContaining({ + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + }), stdio: "inherit", timeout: 180000, - }, + }), ); }); diff --git a/tests/server/pending-openclaw-update.test.js b/tests/server/pending-openclaw-update.test.js index 478d8a5d..d798ec90 100644 --- a/tests/server/pending-openclaw-update.test.js +++ b/tests/server/pending-openclaw-update.test.js @@ -70,11 +70,16 @@ describe("server/pending-openclaw-update", () => { expect(execSyncImpl.mock.calls[0][0]).toBe( "npm install 'openclaw@1.1.0' --omit=dev --no-save --save=false --package-lock=false --prefer-online", ); - expect(execSyncImpl.mock.calls[0][1]).toEqual({ - cwd: expect.stringMatching(new RegExp(`^${runtimeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-pending-[^/]+$`)), - stdio: "inherit", - timeout: 180000, - }); + expect(execSyncImpl.mock.calls[0][1]).toEqual( + expect.objectContaining({ + cwd: expect.stringMatching(new RegExp(`^${runtimeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-pending-[^/]+$`)), + env: expect.objectContaining({ + OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", + }), + stdio: "inherit", + timeout: 180000, + }), + ); expect( JSON.parse(fs.readFileSync(path.join(runtimeDir, "package.json"), "utf8")), ).toEqual({ From 44277be668ad5972469f00bc6bf6ac0decb7c796 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 13:09:05 -0700 Subject: [PATCH 21/26] 0.8.7-beta.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ba113830..254e3128 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.5", + "version": "0.8.7-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.5", + "version": "0.8.7-beta.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e7fb8f05..c3df82bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.5", + "version": "0.8.7-beta.6", "publishConfig": { "access": "public" }, From 94d13da94720552a79b65b7ebdb1867c7c4433d0 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 14:30:13 -0700 Subject: [PATCH 22/26] Extend OpenClaw restart wait timeout --- .../js/hooks/use-app-shell-controller.js | 6 +++++- lib/public/js/lib/api.js | 3 ++- tests/frontend/api.test.js | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/public/js/hooks/use-app-shell-controller.js b/lib/public/js/hooks/use-app-shell-controller.js index 9528b89d..d355306c 100644 --- a/lib/public/js/hooks/use-app-shell-controller.js +++ b/lib/public/js/hooks/use-app-shell-controller.js @@ -20,6 +20,7 @@ import { showToast } from "../components/toast.js"; export const useAppShellController = ({ location = "" } = {}) => { const kInitialStatusPollDelayMs = 5000; + const kOpenclawUpdateRestartTimeoutMs = 5 * 60 * 1000; const [onboarded, setOnboarded] = useState(null); const [authEnabled, setAuthEnabled] = useState(false); const [acVersion, setAcVersion] = useState(null); @@ -249,7 +250,10 @@ export const useAppShellController = ({ location = "" } = {}) => { const data = await updateOpenclaw(); if (data?.ok && data?.restarting) { setOpenclawRestarting(true); - await waitForAlphaclawRestart(); + await waitForAlphaclawRestart({ + timeoutMs: kOpenclawUpdateRestartTimeoutMs, + timeoutErrorMessage: "OpenClaw update is taking longer than expected", + }); window.location.reload(); return { ...data, restartHandled: true }; } diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 64a1b894..374e2141 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -536,6 +536,7 @@ export async function waitForAlphaclawRestart({ initialDelayMs = 1500, intervalMs = 1000, timeoutMs = 60000, + timeoutErrorMessage = "AlphaClaw restart is taking longer than expected", } = {}) { const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); await delay(initialDelayMs); @@ -559,7 +560,7 @@ export async function waitForAlphaclawRestart({ await delay(intervalMs); } - throw new Error("AlphaClaw restart is taking longer than expected"); + throw new Error(String(timeoutErrorMessage || "AlphaClaw restart is taking longer than expected")); } export async function fetchSyncCron() { diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 14f1bbf2..d19c926c 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -96,6 +96,25 @@ describe("frontend/api", () => { await assertion; }); + it("waitForAlphaclawRestart uses a custom timeout error message when provided", async () => { + vi.useFakeTimers(); + global.fetch.mockRejectedValue(new Error("offline")); + const api = await loadApiModule(); + + const promise = api.waitForAlphaclawRestart({ + initialDelayMs: 0, + intervalMs: 5, + timeoutMs: 15, + timeoutErrorMessage: "OpenClaw update is taking longer than expected", + }); + const assertion = expect(promise).rejects.toThrow( + "OpenClaw update is taking longer than expected", + ); + + await vi.runAllTimersAsync(); + await assertion; + }); + it("runOnboard sends vars and modelKey payload", async () => { global.fetch.mockResolvedValue(mockJsonResponse(200, { ok: true })); const api = await loadApiModule(); From a05dccd95aeb54d5f6f76e2b1f15ae0042a14461 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 14:35:45 -0700 Subject: [PATCH 23/26] Avoid buffering OpenClaw postinstall output --- lib/server/openclaw-runtime.js | 60 +++++++++++++++++++++------ tests/server/openclaw-runtime.test.js | 45 ++++++++++++++++---- 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/lib/server/openclaw-runtime.js b/lib/server/openclaw-runtime.js index 4c1a659b..8c18d902 100644 --- a/lib/server/openclaw-runtime.js +++ b/lib/server/openclaw-runtime.js @@ -1,4 +1,5 @@ const fs = require("fs"); +const os = require("os"); const path = require("path"); const { kRootDir } = require("./constants"); @@ -178,24 +179,59 @@ const runManagedOpenclawBundledPluginPostinstall = ({ } const env = { ...process.env }; delete env[kDisableBundledPluginPostinstallEnv]; - const output = String( - execSyncImpl( - `${shellQuote(process.execPath)} ${shellQuote(postinstallScriptPath)} 2>&1`, - { - cwd: packageRoot, - env, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 180000, - }, - ) || "", - ).trim(); + const logDir = fsModule.mkdtempSync( + path.join(os.tmpdir(), "openclaw-bundled-postinstall-"), + ); + const logPath = path.join(logDir, "postinstall.log"); + let commandError = null; + let output = ""; + let stdoutFd; + let stderrFd; + try { + stdoutFd = fsModule.openSync(logPath, "a"); + stderrFd = fsModule.openSync(logPath, "a"); + try { + execSyncImpl( + `${shellQuote(process.execPath)} ${shellQuote(postinstallScriptPath)}`, + { + cwd: packageRoot, + env, + stdio: ["ignore", stdoutFd, stderrFd], + timeout: 180000, + }, + ); + } catch (error) { + commandError = error; + } + } finally { + if (typeof stdoutFd === "number") { + try { + fsModule.closeSync(stdoutFd); + } catch {} + } + if (typeof stderrFd === "number") { + try { + fsModule.closeSync(stderrFd); + } catch {} + } + try { + output = String(fsModule.readFileSync(logPath, "utf8") || "").trim(); + } catch { + output = ""; + } + try { + fsModule.rmSync(logDir, { recursive: true, force: true }); + } catch {} + } if (output) { logger.log(output); } if (output.includes(kBundledPluginPostinstallFailureMarker)) { throw new Error(output); } + if (commandError) { + throw commandError; + } return true; }; diff --git a/tests/server/openclaw-runtime.test.js b/tests/server/openclaw-runtime.test.js index e9337600..ebe25276 100644 --- a/tests/server/openclaw-runtime.test.js +++ b/tests/server/openclaw-runtime.test.js @@ -229,7 +229,9 @@ describe("server/openclaw-runtime", () => { ); fs.mkdirSync(path.dirname(postinstallScriptPath), { recursive: true }); fs.writeFileSync(postinstallScriptPath, "console.log('postinstall');\n"); - const execSyncImpl = vi.fn(); + const execSyncImpl = vi.fn((command, options) => { + fs.writeSync(options.stdio[1], "postinstall\n"); + }); const logger = { log: vi.fn() }; const ran = runManagedOpenclawBundledPluginPostinstall({ @@ -246,15 +248,14 @@ describe("server/openclaw-runtime", () => { expect(execSyncImpl.mock.calls[0][1]).toEqual( expect.objectContaining({ cwd: packageRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", expect.any(Number), expect.any(Number)], timeout: 180000, }), ); expect(execSyncImpl.mock.calls[0][1].env).not.toHaveProperty( "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL", ); - expect(logger.log).not.toHaveBeenCalled(); + expect(logger.log).toHaveBeenCalledWith("postinstall"); }); it("throws when bundled plugin postinstall reports a runtime-deps install failure", () => { @@ -267,10 +268,12 @@ describe("server/openclaw-runtime", () => { ); fs.mkdirSync(path.dirname(postinstallScriptPath), { recursive: true }); fs.writeFileSync(postinstallScriptPath, "console.log('postinstall');\n"); - const execSyncImpl = vi.fn( - () => + const execSyncImpl = vi.fn((command, options) => { + fs.writeSync( + options.stdio[1], "[postinstall] could not install bundled plugin deps: Error: npm error ENOSPC", - ); + ); + }); const logger = { log: vi.fn() }; expect(() => @@ -286,6 +289,34 @@ describe("server/openclaw-runtime", () => { ); }); + it("logs buffered postinstall output from a failed command before rethrowing", () => { + const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); + const packageRoot = getManagedOpenclawPackageRoot({ runtimeDir }); + const postinstallScriptPath = path.join( + packageRoot, + "scripts", + "postinstall-bundled-plugins.mjs", + ); + fs.mkdirSync(path.dirname(postinstallScriptPath), { recursive: true }); + fs.writeFileSync(postinstallScriptPath, "console.log('postinstall');\n"); + const error = new Error("boom"); + const execSyncImpl = vi.fn((command, options) => { + fs.writeSync(options.stdio[1], "postinstall failed\n"); + throw error; + }); + const logger = { log: vi.fn() }; + + expect(() => + runManagedOpenclawBundledPluginPostinstall({ + execSyncImpl, + fsModule: fs, + logger, + runtimeDir, + }), + ).toThrow(error); + expect(logger.log).toHaveBeenCalledWith("postinstall failed"); + }); + it("seeds the managed runtime from the bundled OpenClaw version when missing", () => { const runtimeDir = getManagedOpenclawRuntimeDir({ rootDir: tmpDir }); const bundleDir = path.join(tmpDir, "bundle"); From b09c89d8542934ffd93664576d1e75a7ffaa69ee Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 14:39:08 -0700 Subject: [PATCH 24/26] 0.8.7-beta.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 254e3128..f18cc4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.6", + "version": "0.8.7-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.6", + "version": "0.8.7-beta.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c3df82bb..9a379c92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.6", + "version": "0.8.7-beta.7", "publishConfig": { "access": "public" }, From cd60ca1bdde5fc83aac7ae9af266ceb1f7c1a4b1 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 15:00:19 -0700 Subject: [PATCH 25/26] Harden OpenClaw version probes --- lib/server/openclaw-version.js | 25 +++++-- tests/server/openclaw-version.test.js | 104 +++++++++++++++++++------- 2 files changed, 96 insertions(+), 33 deletions(-) diff --git a/lib/server/openclaw-version.js b/lib/server/openclaw-version.js index 1994e3b0..3b2b8184 100644 --- a/lib/server/openclaw-version.js +++ b/lib/server/openclaw-version.js @@ -1,4 +1,4 @@ -const { execSync } = require("child_process"); +const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const { @@ -7,6 +7,7 @@ const { kRootDir, } = require("./constants"); const { normalizeOpenclawVersion } = require("./helpers"); +const { getManagedOpenclawBinPath } = require("./openclaw-runtime"); const { parseJsonObjectFromNoisyOutput } = require("./utils/json"); const createOpenclawVersionService = ({ @@ -25,6 +26,11 @@ const createOpenclawVersionService = ({ const buildOpenclawInstallSpec = (version = "latest") => `openclaw@${String(version || "").trim() || "latest"}`; + const getOpenclawCommandPath = () => { + const managedBinPath = getManagedOpenclawBinPath(); + return fs.existsSync(managedBinPath) ? managedBinPath : "openclaw"; + }; + const readOpenclawVersion = () => { const now = Date.now(); if ( @@ -34,9 +40,9 @@ const createOpenclawVersionService = ({ return kOpenclawVersionCache.value; } try { - const raw = execSync("openclaw --version", { + const raw = execFileSync(getOpenclawCommandPath(), ["--version"], { env: gatewayEnv(), - timeout: 5000, + timeout: 10000, encoding: "utf8", }).trim(); const version = normalizeOpenclawVersion(raw); @@ -60,11 +66,16 @@ const createOpenclawVersionService = ({ }; } try { - const raw = execSync("openclaw update status --json", { + const raw = execFileSync( + getOpenclawCommandPath(), + ["update", "status", "--json"], + { env: gatewayEnv(), - timeout: 8000, - encoding: "utf8", - }).trim(); + timeout: 30000, + maxBuffer: 4 * 1024 * 1024, + encoding: "utf8", + }, + ).trim(); const parsed = parseJsonObjectFromNoisyOutput(raw); if (!parsed) { throw new Error("openclaw update status returned invalid JSON payload"); diff --git a/tests/server/openclaw-version.test.js b/tests/server/openclaw-version.test.js index d02059e3..9a415dca 100644 --- a/tests/server/openclaw-version.test.js +++ b/tests/server/openclaw-version.test.js @@ -1,21 +1,24 @@ const fs = require("fs"); const path = require("path"); const childProcess = require("child_process"); +const { + getManagedOpenclawBinPath, +} = require("../../lib/server/openclaw-runtime"); const { kRootDir } = require("../../lib/server/constants"); const modulePath = require.resolve("../../lib/server/openclaw-version"); -const originalExecSync = childProcess.execSync; +const originalExecFileSync = childProcess.execFileSync; -const loadVersionModule = ({ execSyncMock }) => { - childProcess.execSync = execSyncMock; +const loadVersionModule = ({ execFileSyncMock }) => { + childProcess.execFileSync = execFileSyncMock; delete require.cache[modulePath]; return require(modulePath); }; const createService = ({ isOnboarded = false } = {}) => { - const execSyncMock = vi.fn(); + const execFileSyncMock = vi.fn(); const { createOpenclawVersionService } = loadVersionModule({ - execSyncMock, + execFileSyncMock, }); const gatewayEnv = vi.fn(() => ({ OPENCLAW_GATEWAY_TOKEN: "token" })); const restartGateway = vi.fn(); @@ -24,35 +27,35 @@ const createService = ({ isOnboarded = false } = {}) => { restartGateway, isOnboarded: () => isOnboarded, }); - return { service, gatewayEnv, restartGateway, execSyncMock }; + return { service, gatewayEnv, restartGateway, execFileSyncMock }; }; describe("server/openclaw-version", () => { afterEach(() => { - childProcess.execSync = originalExecSync; + childProcess.execFileSync = originalExecFileSync; delete require.cache[modulePath]; }); it("reads current version and uses cache within TTL", () => { - const { service, gatewayEnv, execSyncMock } = createService(); - execSyncMock.mockReturnValue("openclaw 1.2.3\n"); + const { service, gatewayEnv, execFileSyncMock } = createService(); + execFileSyncMock.mockReturnValue("openclaw 1.2.3\n"); const first = service.readOpenclawVersion(); const second = service.readOpenclawVersion(); expect(first).toBe("1.2.3"); expect(second).toBe("1.2.3"); - expect(execSyncMock).toHaveBeenCalledTimes(1); - expect(execSyncMock).toHaveBeenCalledWith("openclaw --version", { + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + expect(execFileSyncMock).toHaveBeenCalledWith("openclaw", ["--version"], { env: gatewayEnv(), - timeout: 5000, + timeout: 10000, encoding: "utf8", }); }); it("returns update availability when latest version is newer", async () => { - const { service, execSyncMock } = createService(); - execSyncMock.mockReturnValueOnce("openclaw 1.2.3").mockReturnValueOnce( + const { service, execFileSyncMock } = createService(); + execFileSyncMock.mockReturnValueOnce("openclaw 1.2.3").mockReturnValueOnce( JSON.stringify({ availability: { available: true, latestVersion: "1.3.0" }, }), @@ -69,8 +72,8 @@ describe("server/openclaw-version", () => { }); it("parses update status json from noisy CLI output", async () => { - const { service, execSyncMock } = createService(); - execSyncMock + const { service, execFileSyncMock } = createService(); + execFileSyncMock .mockReturnValueOnce("openclaw 1.2.3") .mockReturnValueOnce( `[plugins] [auth]\n${JSON.stringify({ @@ -89,8 +92,8 @@ describe("server/openclaw-version", () => { }); it("returns error status when update status command fails", async () => { - const { service, execSyncMock } = createService(); - execSyncMock + const { service, execFileSyncMock } = createService(); + execFileSyncMock .mockReturnValueOnce("openclaw 1.2.3") .mockImplementationOnce(() => { throw new Error("status check failed"); @@ -106,8 +109,8 @@ describe("server/openclaw-version", () => { }); it("queues an exact openclaw update and requests restart", async () => { - const { service, restartGateway, execSyncMock } = createService(); - execSyncMock.mockReturnValueOnce("openclaw 1.0.0").mockReturnValueOnce( + const { service, restartGateway, execFileSyncMock } = createService(); + execFileSyncMock.mockReturnValueOnce("openclaw 1.0.0").mockReturnValueOnce( JSON.stringify({ availability: { available: true, latestVersion: "1.1.0" }, }), @@ -144,10 +147,10 @@ describe("server/openclaw-version", () => { }); it("returns without restart when openclaw is already current", async () => { - const { service, restartGateway, execSyncMock } = createService({ + const { service, restartGateway, execFileSyncMock } = createService({ isOnboarded: true, }); - execSyncMock.mockReturnValueOnce("openclaw 1.1.0").mockReturnValueOnce( + execFileSyncMock.mockReturnValueOnce("openclaw 1.1.0").mockReturnValueOnce( JSON.stringify({ availability: { available: false, latestVersion: "1.1.0" }, }), @@ -172,8 +175,8 @@ describe("server/openclaw-version", () => { }); it("falls back to latest marker when version resolution fails", async () => { - const { service, execSyncMock } = createService(); - execSyncMock + const { service, execFileSyncMock } = createService(); + execFileSyncMock .mockReturnValueOnce("openclaw 1.0.0") .mockImplementationOnce(() => { throw new Error("status check failed"); @@ -207,8 +210,8 @@ describe("server/openclaw-version", () => { }); it("returns 500 when it cannot write the pending update marker", async () => { - const { service, execSyncMock } = createService(); - execSyncMock.mockReturnValueOnce("openclaw 1.0.0").mockReturnValueOnce( + const { service, execFileSyncMock } = createService(); + execFileSyncMock.mockReturnValueOnce("openclaw 1.0.0").mockReturnValueOnce( JSON.stringify({ availability: { available: true, latestVersion: "1.1.0" }, }), @@ -229,4 +232,53 @@ describe("server/openclaw-version", () => { writeSpy.mockRestore(); }); + it("uses the managed OpenClaw binary for version and update-status probes when present", async () => { + const existsSpy = vi.spyOn(fs, "existsSync"); + const managedBinPath = getManagedOpenclawBinPath(); + existsSpy.mockImplementation((targetPath) => { + if (targetPath === managedBinPath) return true; + return false; + }); + const { service, execFileSyncMock, gatewayEnv } = createService(); + execFileSyncMock + .mockReturnValueOnce("openclaw 2026.4.1") + .mockReturnValueOnce( + JSON.stringify({ + availability: { available: true, latestVersion: "2026.4.5" }, + }), + ); + + const status = await service.getVersionStatus(true); + + expect(status).toEqual({ + ok: true, + currentVersion: "2026.4.1", + latestVersion: "2026.4.5", + hasUpdate: true, + }); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 1, + managedBinPath, + ["--version"], + { + env: gatewayEnv(), + timeout: 10000, + encoding: "utf8", + }, + ); + expect(execFileSyncMock).toHaveBeenNthCalledWith( + 2, + managedBinPath, + ["update", "status", "--json"], + { + env: gatewayEnv(), + timeout: 30000, + maxBuffer: 4 * 1024 * 1024, + encoding: "utf8", + }, + ); + + existsSpy.mockRestore(); + }); + }); From e82bbc5b3747ff49ab040eb14164f0b365d81670 Mon Sep 17 00:00:00 2001 From: Chrys Bader Date: Mon, 6 Apr 2026 15:02:52 -0700 Subject: [PATCH 26/26] 0.8.7-beta.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f18cc4ef..bf67670b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.7", + "version": "0.8.7-beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.7", + "version": "0.8.7-beta.8", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9a379c92..153d2b4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chrysb/alphaclaw", - "version": "0.8.7-beta.7", + "version": "0.8.7-beta.8", "publishConfig": { "access": "public" },