@workwright/cli
v0.1.2
Published
Fail-closed release manager for a single Workwright Botmux runtime
Downloads
33
Readme
@workwright/cli
面向单台 Ubuntu/Lightsail VM、user-level Botmux 的 fail-closed release manager。它把本仓库已有的 runtime scripts/lib 安装进 versioned release,并以 exact target、verified ingress fence、atomic pointer、append-only ledger 和 semantic health gate 管理升级与回滚。
Source merge、npm publish 与 VM rollout 是三个独立状态。安装前必须从批准的 registry 验证 exact version,或使用维护者提供并经过 SHA-256 校验的 tarball;不能从 GitHub branch 状态推断 package 已发布。
首次纳管
目标 VM需在 trusted absolute /usr/bin/node提供 Node.js >=22(manager entrypoint使用 absolute shebang,不经 PATH解析 node),Botmux已完成 setup,且 ~/.botmux/bots.json至少包含一个 bot:
# Run as one fail-fast unit: no npm or Workwright command runs after a failed path check.
(
set -eu
umask 077
case "$HOME" in /*) ;; *) exit 1 ;; esac
test ! -L "$HOME"
test -d "$HOME"
test -O "$HOME"
home_mode="$(/usr/bin/stat -c '%a' "$HOME")"
test "$((8#$home_mode & 0022))" -eq 0
test "$(/usr/bin/readlink -f -- "$HOME")" = "$HOME"
operator_uid="$(/usr/bin/id -u)"
assert_trusted_ancestors() {
local child="$1" parent parent_mode parent_uid child_uid
while :; do
parent="$(/usr/bin/dirname -- "$child")" || return 1
test "$parent" != "$child" || break
test ! -L "$parent" || return 1
test -d "$parent" || return 1
parent_uid="$(/usr/bin/stat -c '%u' "$parent")" || return 1
case "$parent_uid" in 0|"$operator_uid") ;; *) return 1 ;; esac
parent_mode="$(/usr/bin/stat -c '%a' "$parent")" || return 1
if test "$((8#$parent_mode & 0022))" -ne 0; then
test "$((8#$parent_mode & 01000))" -ne 0 || return 1
child_uid="$(/usr/bin/stat -c '%u' "$child")" || return 1
case "$child_uid" in 0|"$operator_uid") ;; *) return 1 ;; esac
fi
child="$parent"
done
}
assert_trusted_ancestors "$HOME"
test ! -L /usr/bin/node
test -f /usr/bin/node
test -x /usr/bin/node
node_uid="$(/usr/bin/stat -c '%u' /usr/bin/node)"
case "$node_uid" in 0|"$operator_uid") ;; *) exit 1 ;; esac
node_mode="$(/usr/bin/stat -c '%a' /usr/bin/node)"
test "$((8#$node_mode & 0022))" -eq 0
assert_trusted_ancestors /usr/bin/node
node_identity="$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' /usr/bin/node)"
run_node() {
local current_node_uid current_node_mode current_node_identity
test ! -L /usr/bin/node || return 1
test -f /usr/bin/node || return 1
test -x /usr/bin/node || return 1
current_node_uid="$(/usr/bin/stat -c '%u' /usr/bin/node)" || return 1
case "$current_node_uid" in 0|"$operator_uid") ;; *) return 1 ;; esac
current_node_mode="$(/usr/bin/stat -c '%a' /usr/bin/node)" || return 1
test "$((8#$current_node_mode & 0022))" -eq 0 || return 1
current_node_identity="$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' /usr/bin/node)" || return 1
test "$current_node_identity" = "$node_identity" || return 1
assert_trusted_ancestors /usr/bin/node || return 1
/usr/bin/node "$@"
}
package_verifier=
IFS= read -r -d '' package_verifier <<'NODE' || :
const fs = require('node:fs');
const path = require('node:path');
const [entryArg, expectedName, expectedVersion, binKey, ownerPolicy] = process.argv.slice(1);
const uid = process.getuid();
const allowedOwner = owner => owner === uid || (ownerPolicy === 'trusted' && owner === 0);
const inside = (root, value) => {
const relative = path.relative(root, value);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const identity = stat => [stat.dev, stat.ino, stat.uid, stat.mode, stat.size, stat.mtimeMs].join(':');
const readPackage = file => {
const before = fs.lstatSync(file);
if (!before.isFile() || before.isSymbolicLink() || before.size > 2 * 1024 * 1024) process.exit(1);
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
if (identity(before) !== identity(fs.lstatSync(file))) process.exit(1);
return value;
};
const entry = fs.realpathSync(entryArg);
const entryInfo = fs.statSync(entry);
if (!entryInfo.isFile()) process.exit(1);
const entryIdentity = identity(entryInfo);
let root = path.dirname(entry);
let metadata;
let pkg;
for (let depth = 0; depth < 64; depth += 1) {
metadata = path.join(root, 'package.json');
try { pkg = readPackage(metadata); } catch { pkg = undefined; }
if (pkg?.name === expectedName) break;
const parent = path.dirname(root);
if (parent === root) process.exit(1);
root = parent;
}
if (pkg?.name !== expectedName) process.exit(1);
root = fs.realpathSync(root);
const rootRaw = fs.lstatSync(root);
if (!rootRaw.isDirectory() || rootRaw.isSymbolicLink() || !allowedOwner(rootRaw.uid)
|| (rootRaw.mode & 0o022) !== 0) process.exit(1);
const pending = [root];
const snapshot = [];
while (pending.length) {
const file = pending.pop();
const before = fs.lstatSync(file);
if (snapshot.length >= 250000) process.exit(1);
if (before.isSymbolicLink()) {
if (!allowedOwner(before.uid)) process.exit(1);
const lexical = path.resolve(path.dirname(file), fs.readlinkSync(file));
const target = fs.realpathSync(file);
if (!inside(root, lexical) || !inside(root, target)) process.exit(1);
const targetStat = fs.statSync(target);
if ((!targetStat.isFile() && !targetStat.isDirectory()) || !allowedOwner(targetStat.uid)
|| (targetStat.mode & 0o022) !== 0) process.exit(1);
} else if (before.isDirectory()) {
if (!allowedOwner(before.uid) || (before.mode & 0o022) !== 0) process.exit(1);
for (const name of fs.readdirSync(file).sort()) pending.push(path.join(file, name));
} else if (before.isFile()) {
if (!allowedOwner(before.uid) || (before.mode & 0o022) !== 0) process.exit(1);
} else process.exit(1);
snapshot.push([file, identity(before)]);
}
for (const [file, before] of snapshot) {
try { if (identity(fs.lstatSync(file)) !== before) process.exit(1); } catch { process.exit(1); }
}
pkg = readPackage(metadata);
if (pkg.name !== expectedName || (expectedVersion !== '*' && pkg.version !== expectedVersion)) process.exit(1);
const declared = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[binKey];
if (typeof declared !== 'string' || !declared || path.isAbsolute(declared)) process.exit(1);
const lexicalDeclared = path.resolve(root, declared);
if (!inside(root, lexicalDeclared) || fs.realpathSync(lexicalDeclared) !== entry) process.exit(1);
if (identity(fs.statSync(entry)) !== entryIdentity) process.exit(1);
process.stdout.write(root);
NODE
verify_package() {
run_node -e "$package_verifier" "$@"
}
npm_requested="$(command -v npm)"
case "$npm_requested" in /*) ;; *) exit 1 ;; esac
npm_requested_type="$(/usr/bin/stat -c '%F' "$npm_requested")"
case "$npm_requested_type" in 'regular file'|'symbolic link') ;; *) exit 1 ;; esac
npm_requested_uid="$(/usr/bin/stat -c '%u' "$npm_requested")"
case "$npm_requested_uid" in 0|"$operator_uid") ;; *) exit 1 ;; esac
assert_trusted_ancestors "$npm_requested"
npm_realpath="$(/usr/bin/readlink -f -- "$npm_requested")"
test -f "$npm_realpath"
test -x "$npm_realpath"
npm_uid="$(/usr/bin/stat -c '%u' "$npm_realpath")"
case "$npm_uid" in 0|"$operator_uid") ;; *) exit 1 ;; esac
npm_mode="$(/usr/bin/stat -c '%a' "$npm_realpath")"
test "$((8#$npm_mode & 0022))" -eq 0
assert_trusted_ancestors "$npm_realpath"
npm_identity="$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' "$npm_realpath")"
npm_package_root="$(verify_package "$npm_realpath" npm '*' npm trusted)"
assert_trusted_ancestors "$npm_package_root"
run_npm() {
test "$(/usr/bin/stat -c '%d:%i:%s:%y:%z:%f:%u' "$npm_realpath")" = "$npm_identity" || return 1
assert_trusted_ancestors "$npm_realpath" || return 1
test "$(verify_package "$npm_realpath" npm '*' npm trusted)" = "$npm_package_root" || return 1
run_node "$npm_realpath" "$@"
}
for directory in \
"$HOME/.local" \
"$HOME/.local/npm-global" \
"$HOME/.local/npm-global/lib" \
"$HOME/.local/npm-global/lib/node_modules" \
"$HOME/.local/bin"
do
if [ -e "$directory" ] || [ -L "$directory" ]; then
test ! -L "$directory"
test -d "$directory"
test -O "$directory"
test "$(/usr/bin/stat -c '%a' "$directory")" = "700"
else
/usr/bin/install -d -m 0700 "$directory"
fi
done
export npm_config_prefix="$HOME/.local/npm-global"
test "$(run_npm prefix --global)" = "$HOME/.local/npm-global"
test -O "$HOME/.local/npm-global"
test "$(/usr/bin/stat -c '%a' "$HOME/.local")" = "700"
test "$(/usr/bin/stat -c '%a' "$HOME/.local/npm-global")" = "700"
test "$(/usr/bin/stat -c '%a' "$HOME/.local/npm-global/lib/node_modules")" = "700"
test "$(/usr/bin/stat -c '%a' "$HOME/.local/bin")" = "700"
test "$(run_node -p 'Number(process.versions.node.split(".")[0]) >= 22')" = "true"
(umask 077; run_npm install --global --ignore-scripts --prefix "$HOME/.local/npm-global" -- @workwright/[email protected])
bootstrap_package="$HOME/.local/npm-global/lib/node_modules/@workwright/cli"
test ! -L "$bootstrap_package"
test -d "$bootstrap_package"
test -O "$bootstrap_package"
bootstrap_link="$HOME/.local/npm-global/bin/workwright"
test -L "$bootstrap_link"
test "$(/usr/bin/stat -c '%u' "$bootstrap_link")" = "$operator_uid"
bootstrap_cli="$(/usr/bin/readlink -f -- "$bootstrap_link")"
test "$bootstrap_cli" = "$(/usr/bin/readlink -f -- "$bootstrap_package/bin/workwright.js")"
test ! -L "$bootstrap_cli"
test -f "$bootstrap_cli"
test -x "$bootstrap_cli"
test "$(/usr/bin/stat -c '%u' "$bootstrap_cli")" = "$operator_uid"
bootstrap_mode="$(/usr/bin/stat -c '%a' "$bootstrap_cli")"
test "$((8#$bootstrap_mode & 0022))" -eq 0
assert_trusted_ancestors "$bootstrap_cli"
test "$(verify_package "$bootstrap_cli" @workwright/cli 0.1.2 workwright current)" = "$bootstrap_package"
run_node "$bootstrap_cli" plan --release 0.1.2 --botmux 3.17.0
run_node "$bootstrap_cli" install --release 0.1.2 --botmux 3.17.0
)npm global prefix必须由当前用户拥有、当前用户可写,且不能 group/other-writable;不要用 sudo npm i -g。不要把 prefix直接设为 $HOME/.local:npm bootstrap的 bin/workwright 会与 manager拥有的 $HOME/.local/bin/workwright 冲突。首次纳管不修改~/.npmrc,也不通过PATH调用刚安装的CLI;它固定使用纳管前捕获并验证的canonical npm script,并在每次npm调用前验证声明该bin的整个npm package tree。安装后也会递归验证exact @workwright/cli package tree及其声明的npm-prefix entrypoint。两类tree都拒绝untrusted owner、group/other-write、special node、escaping symlink与扫描中的identity drift。成功后再把$HOME/.local/bin:$HOME/.local/npm-global/bin顺序写入对应shell profile;managed stable bin优先,独立npm bootstrap bin作为初装与delegation入口。
bootstrap在任何mkdir或npm调用前先要求$HOME是absolute canonical path、plain directory、current-user-owned且mode & 0022 == 0。它还逐级验证canonical ancestor:每层parent都必须由root或current UID拥有;group/other-writable parent还必须有sticky bit,并且直接child也必须由root或current UID拥有。普通0775 parent与other-UID-owned 0755 parent都会拒绝。$HOME本身或其路径中的symlink都会直接中止,脚本不会尝试自动修权限。
CLI runtime trust的最低mode边界是拒绝 mode & 0022(group/other-write);fresh bootstrap示例有意采用更严格的exact 0700 baseline,把整个 $HOME/.local delivery namespace保持private。已有安全但更宽的read/execute mode不应被脚本静默修改;operator应先核对后决定是否沿用或迁移到新的0700 tree。0775等group-writable ancestor不满足最低边界,必须人工处理。
上面的 bootstrap block只适用于 fresh、已由operator确认的目录。已有 npm prefix、Botmux package tree、legacy ~/.workwright/runtime或~/bin entrypoint必须先人工核对owner、mode、symlink target和authority,再选择新的private prefix或受控迁移;不要用递归 chmod猜测修复。CLI不会自动 chmod、接管或删除无法证明安全的legacy tree,而是在执行 npm/Botmux child前fail closed。
bootstrap使用operator预先批准的npm executable、registry与auth config作为外部信任边界。后续dist.integrity校验只证明下载的tarball与该chosen registry返回的metadata一致,不声明其来自npmjs.org,也不替operator选择registry/account。
@workwright/cli 没有 preinstall、install 或 postinstall script;安装 npm package 本身不修改 Botmux/runtime。只有显式执行 install、upgrade 或 rollback 才进入 mutation transaction。
首次成功安装后执行 smoke check:
test -L "$HOME/.local/bin/workwright"
readlink -f "$HOME/.local/bin/workwright"
workwright status --json
workwright doctornpm global bin 中的 bootstrap CLI 和 ~/.local/bin/workwright 不构成两个独立 control plane:已有 managed current 时,npm 入口的 operational command会先校验 release,再委派给 current/bin/workwright.js。因此 shell 仍缓存 npm bin 路径时,也不会继续执行旧 manager schema。
命令与 exact target
workwright plan --release 0.2.0 --botmux 3.17.0
workwright install --release 0.1.0 --botmux 3.17.0
workwright upgrade --release 0.2.0 --botmux 3.17.0
workwright status --json
workwright doctor
workwright rollback
workwright rollback --to 0.1.0latest、npm dist-tag、semver range、v0.1.0 和 abbreviated Git SHA 都会被拒绝。完整 Git SHA release 必须使用已知 SHA-256 的 tarball:
workwright upgrade \
--release 0123456789abcdef0123456789abcdef01234567 \
--botmux 3.17.0 \
--artifact ./workwright-cli.tgz \
--sha256 <64-CHAR-SHA256>plan 会实际取得并校验指定 Workwright source/artifact identity;不会静默忽略 --artifact 或 --sha256。它不运行 package lifecycle script:以 --ignore-scripts=true 检查 staged package、protocol 和 PM2 shape,不做 node-pty native load probe。PM2 God不存在时使用 no-birth evidence返回 empty fleet,不调用会 lazy-start persistent God的 pm2 jlist;markers与 /proc evidence矛盾时 fail closed。PM2 probe和Doctor会先证明 ~/.botmux 是 current-user-owned、无 symlink ancestor且 group/other bits均为零的 private namespace;只有在这个边界内,才兼容 PM2 6.0.14 的 PM2_HOME=0775、pm2.pid=0664和socket 0775,同时仍拒绝 descendant wrong owner/type/symlink、other-write与process identity mismatch。真正的 normal-lifecycle compatibility probe只在持有 deployment lock且每个 child都有独立 durable command fence的 install、upgrade 或 rollback transaction内执行。
Live fleet 支持矩阵
| 当前 Botmux fleet | 行为 |
|---|---|
| 所有 live daemon 都发布 target 要求的 exact supervisorShutdownProtocol,且 PM2 policy 可证明安全 | 允许:authenticated idle 后调用当前 exact Botmux 的 stop,验证 fleet stopped,再 mutation/start |
| 任一 live daemon 缺 descriptor、protocol 不匹配或 PM2 policy 不安全 | 拒绝;即使传 --bootstrap-botmux 也不会继续 |
| 没有 live daemon,但仍有 offline/stopped/errored legacy PM2 row | 要求显式 --bootstrap-botmux;由已 staged/verified 的 target CLI 做 non-live row retirement,不调用能力未知的 old CLI。若 previous package 不能通过 exact artifact、rollback 与 health-contract preflight,仍在 mutation 前拒绝 |
| 没有 live daemon,也没有 legacy PM2 row | 可继续首次 start |
因此,CLI 不自动支持 Botmux 2.108 → 3.17。live 场景下,本实现没有证据证明 2.108 daemon 具备 3.17 的 authenticated supervisor shutdown/admission-drain contract;idle snapshot 到 legacy stop 之间仍可能进入新 turn。即使通过本 CLI 之外、machine-verifiable 的 ingress/停服 runbook 把旧 fleet 变成 verified stopped,CLI 也只会重新评估,并且仍要求 previous package 通过 exact rollback artifact 与 health-contract preflight。人工 maintenance window 或 --bootstrap-botmux flag 本身不算 fence。
3.17 contract 下,botmux stop 在 daemon 内取得 per-bot admission mutation lease、drain 已进入的 admission,再由 CLI 验证 PM2 core rows stopped 且没有 live daemon descriptor。transaction 在这之后才允许修改 global package。代价是 upgrade/rollback 有明确的短暂停服窗口;CLI 不会主动消费或重放外部平台尚未投递的消息。
Transaction 顺序
每次 mutation 按以下顺序执行:
- 校验 Workwright release 与 Botmux 都是 exact target;检查
~/.botmuxsecure-host parent/secret,以及 Workwright runtime root、releases/backups/locks、~/.local/bin、ledger/pending/lifecycle fence 的 current-UID ownership、plain-node、ancestor 与 mode trust boundary。已有 symlink ancestor 或 group/other-writable owned path 会在 lock 创建前被拒绝。 - 以 exclusive
mkdir取得包含 PID、Linux/procprocess-start identity 与 boot identity 的 atomic local deployment lock。任何 pre-existing lock(包括看起来 stale 的 lock)都 fail closed,不做自动 rename、unlink 或 stale recovery,避免 delayed breaker误删另一个进程刚创建的 live lock。随后在botmux.service.d/50-workwright-maintenance.conf持久安装ConditionPathExists=!<runtime>/maintenance-active,执行systemctl --user daemon-reload,并用systemctl show/catmachine-verify exact loaded drop-in;user systemd或unit不可用时不开始 transaction。 - 把 npm script固定为 canonical realpath,证明其来自
package.json声明的exact npm bin,并在每次npm process前重新扫描整个imported package tree;同时 capture/revalidate trusted absolute Node。npm/PM2/Botmux JavaScript CLI全部通过nodeRealpath scriptRealpath ...执行,不让#!/usr/bin/env node重新信任PATH;PM2/Botmux和Doctor的package-backed probe同样先验证整个Botmux tree。所有 Workwright child spawn(包括 managed CLI delegation)在同步 spawn窗口把继承umask收紧为operator_umask | 0077,spawn返回后立即恢复parent;npm lifecycle grandchildren继承同一private umask。pending会持久化 Node/npm executable full identity、npm package root及 prefix/root directorydev/ino,recovery只使用原 canonical authority。Doctor同样通过 trusted absolute Python执行。Workwright registry artifact fetch只能使用 captured Node/npm package authority;tarball extraction使用 capture后再次验证的 trusted absolutetar。operator提供的--artifact会先复制进 mode0700的 private temp,再对 private bytes校验 SHA-256并解包,不会 hash原路径后再次读取原路径。 - 每个可能产生持续副作用的 child在 spawn紧邻处写入独立 durable
lifecycle-fence.json,spawn后立即记录 PGID、leader PID与 process-start identity。staged normal install、独立 native probe、global install/uninstall、botmux stop/botmux start及 recovery/compensation都使用同一规则;script-disablednpm pack不提前占用 fence。process group未证明退出,或 owner在 durable spawn handshake完成前消失时,后续 mutation fail closed。 - 只下载一次 exact Botmux tarball:读取 npm
dist.integrity、计算 SHA-256,在临时 prefix 以 normal lifecycle 安装并 load-probenode-pty。staged与global install后都会递归验证整个Botmux package tree为current-owner且无group/other-write;npm生成的symlink只在target仍位于同一package tree且target安全时允许。后续 global install 使用同一份 tarball bytes,digest drift 会在 npm mutation 前被拒绝;legacy或lifecycle重新引入的insecure mode不会被自动修复。 - 通过 daemon HMAC
GET /api/sessions读取真实 session status。working、starting、未知 schema/status 或 descriptor ownership 不可证明都会拒绝;不会依据 process name、age 或 stale metadata 猜 busy。Doctor 还会按 exactBOTMUX_SESSION_IDconfig把 Codex subagent sibling归属到对应 active Botmux session,不把继承同一 session identity 的子 agent误报成 orphan。 - 从
releases/<id>.partial生成逐文件 SHA-256 manifest并 rename;复制时清除所有 group/other write bits,reuse/rollback verification也会拒绝 insecure mode。随后创建 allowlisted state backup、preparedledger 和 durablepending-transaction.json,再以同一 transaction ID创建并 fsync mode0600的maintenance-activesentinel;sentinel建立前不调用 stop。 - 由当前 exact Botmux 建立 stopped ingress fence;stop返回后,以 upstream-compatible
{pid,procStart,bootId}payload用O_EXCL取得~/.botmux/pm2-fleet-mutation.lock,并在持锁后重新确认 fleet stopped。stop→lock gap若被另一个 start占用,会释放锁并在任何 package mutation前保留 pending/sentinel后中止。fleet lock覆盖 exact Botmux install和atomiccurrent切换;它在 intentional targetbotmux start前释放,sentinel继续阻止 systemd autostart。 - health gate 验证 package version、runtime build ID、新 daemon 与 dashboard process generation、authenticated sessions、descriptor/PM2 PID、exact exec path与per-role cwd(dashboard=
botmux.packageDir,daemon=paths.botmuxDir)、dashboard/__health、restart/unstable counters,以及连续 120 秒稳定窗口。workwright-doctorexit1可 degraded commit,exit2阻止 commit。health通过且completion ledger durable后,才按 exact transaction ID删除并 fsync sentinel,最后清除 pending;若在这两次cleanup之间 crash,reconciliation接受已删除 sentinel但仍存在的 committed pending。
命令使用 argv array、cwd=$HOME、bounded timeout/output。POSIX mutating command运行在独立 process group;timeout/overflow时执行 group TERM→KILL并确认 quiesced。preflight阶段无法证明 descendant已停止时保留 command fence;global mutation阶段还会把同一 process-group identity写入 pending transaction。后续 reconciliation在证明该 identity已消失前执行零 package mutation。若进程在 spawn handshake窗口 hard-crash而 durable child identity缺失,则进入 manual adjudication,不会猜测 child从未启动。
Recovery 与 rollback
transaction backup 会保存 target tarball;存在 previous Botmux 时,也保存 preflighted previous tarball及 SHA-256。进程若在 prepared 之后退出,下一次 mutation 先读取 pending record、exact-tx maintenance sentinel和ledger,不会从当前目录状态重新猜 previous version。crash发生在 pending durable但 sentinel尚未建立的唯一安全窗口时,recovery仅允许 prepared phase补建 sentinel;后续phase缺失、wrong-tx、wrong-owner或wrong-mode sentinel一律 fail closed。committed-but-sentinel/pending未清理的transaction会先重新health,再按 sentinel→pending顺序完成cleanup。
MVP不会自动删除任何 pre-existing deployment lock <runtime>/locks/deploy.lock 或 ~/.botmux/pm2-fleet-mutation.lock,即使其PID/boot identity显示stale;这避免 reduced stale-breaker误删另一个 Workwright或 native Botmux刚创建的新lock。遇到该错误时,operator必须先确认没有 Workwright、Botmux或 npm lifecycle process仍在运行,再人工删除对应的exact lock path并重新运行受控recovery。manager只会 inode-bound地释放自己本次 O_EXCL取得的lock。
升级失败时,CLI 先把 failed state 写入 atomic failed-state archive,再 verified-stop 当前 fleet,使用 durable exact previous tarball恢复 package、切回 ledger previous release、start 并再次 health check。首次安装没有 previous package时,失败补偿会停止可能已启动的新 fleet、uninstall 由 transaction 创建的 Botmux package,并移除 owned current pointer;任何一步无法证明 ownership/quiescence都会保留 pending并要求人工处理。
deployments.jsonl 是 append-only,记录 prepared、committed、rolled_back、failed。rollback 的默认 target来自 ledger previous known-good,也可用 --to <exact-release>;绝不按 release 目录排序猜测。
Botmux/application state 可能经历不可逆 migration,因此 CLI 永不自动回灌 state backup。人工恢复前必须检查 RESTORE.md、backup-plan.json、state-before/state-manifest.json 和 failed-state/state-manifest.json,并按对应 Botmux migration runbook判断。Lightsail snapshot仍只是关键任务的人工兜底,不是日常 rollout transaction。
Backup 与文件布局
state archive 是显式 allowlist:bots.json、.env、.dashboard-port、.dashboard-secret、.dashboard-token、完整 data/ tree,以及存在时的 user systemd botmux.service。目录为 0700,复制文件与manifest为0600并记录 SHA-256;symlink、special file和超过 2 GiB 的archive会被拒绝。logs、pm2、node_modules 与 runtime release artifact 不在 state archive 中。
~/.local/share/workwright/runtime/
├── releases/<exact-id>/ # scripts/lib + checksum manifest;不含 state/config/secret
├── current -> releases/<id> # atomic release pointer
├── deployments.jsonl # append-only ledger
├── pending-transaction.json # crash reconciliation record;commit后删除
├── lifecycle-fence.json # lifecycle spawn/quiescence fence;正常完成后删除
├── maintenance-active # exact-tx systemd autostart sentinel;completion ledger后删除
├── backups/<transaction-id>/ # state archive、failed state、exact npm artifacts
└── locks/deploy.lock/
~/.local/bin/ # 指向 current 内 allowlisted entrypoint 的 stable symlink
~/.botmux/
└── pm2-fleet-mutation.lock # 仅 mutation窗口存在;与 Botmux自身 fleet lock兼容
~/.config/systemd/user/botmux.service.d/
└── 50-workwright-maintenance.conf # persistent ConditionPathExists guard测试或人工裁决可使用 absolute path override:WORKWRIGHT_RUNTIME_DIR、WORKWRIGHT_BIN_DIR、WORKWRIGHT_BOTMUX_DIR;relative override会被拒绝。
已有 managed ~/.workwright/runtime 且 XDG root没有 managed marker时,CLI继续在 legacy root原位管理,并把新 stable entrypoint写入 ~/.local/bin;不会自动删除旧 runtime、~/bin entrypoint或用户文件。若 legacy 与 XDG 两边同时包含 managed state,CLI拒绝静默选择 authority,必须由 operator人工核对后以 WORKWRIGHT_RUNTIME_DIR 指定。
~/.local/bin中不存在的 allowlisted entrypoint会创建 symlink;已由本 manager管理的 symlink会刷新。每个 create/refresh先写并 fsync per-action entrypoints.json intent,目录 mutation fsync完成后才记录 completed;crash recovery仅在能证明 link ownership未变化时撤销。任何 unknown user file/symlink都会 fail closed并要求 operator人工移动,不会自动 takeover、删除或备份。generic stable allowlist不暴露 customer-specific pull-all.sh、workwright-repo-sync、workwright-report-card,也不暴露 deprecated、zero-mutation workwright-bootstrap。
已知限制
- 仅支持单台 Linux VM 与 user-level Botmux;不创建 Docker、Kamal、Ansible、SSM、root/system service 或 fleet rollout。
- 依赖 target package 内可验证的 CLI、PM2、runtime build ID、supervisor shutdown protocol、daemon descriptor/session API 与 dashboard health contract;缺失或未知时停止。
- GitHub Release provenance、多机 canary和Lightsail snapshot编排属于后续交付层。
- deployment lock与Botmux fleet lock都采用 pre-existing lock manual fail-closed策略,不自动判断或清理 stale lock。人工删除
<runtime>/locks/deploy.lock或~/.botmux/pm2-fleet-mutation.lock前,operator必须先从进程、pending/fence与systemd状态证明所有 Workwright、Botmux和 npm lifecycle mutation均已停止,然后重新运行受控recovery。 - upstream Botmux目前不会读取 Workwright
pending-transaction.json/maintenance-active。active boundary期间直接运行 nativebotmux start属于不支持的operator操作;不要手工删除 sentinel或pending。先运行workwright status --json或workwright doctor确认 boundary,再通过下一次受控 install/upgrade/rollback调用触发 reconciliation。systemd autostart由persistent drop-in阻止,但此contract不声称拦截绕过systemd/Workwright的人工 native start。 status --json会显示 safe pending/fence/maintenance/lock identity与phase;malformed或untrusted lock会成为对应 error和exit2,不会伪装成“无锁”。active deployment boundary期间,status与doctor跳过 package-backed Botmux probe,避免与 npm mutation竞态。它们不输出 backup artifact path、secret或raw doctor details。
npm release
.github/workflows/publish-runtime-cli.yml 只响应 runtime-cli-v<package-version> tag。workflow 会验证 tag 与 packages/runtime-cli/package.json 的 exact version 一致、tagged commit 属于 origin/main,然后运行 package tests、syntax check 和 pack dry-run;全部通过后才调用 npm publish --access public。
GitHub feature branch
-> PR + Ubuntu CI
-> merge main
-> runtime-cli-v<exact-version> tag
-> GitHub Actions OIDC
-> npm @workwright/cli@<exact-version>
-> per-VM workwright plan / upgrade
-> status + Doctor + runtime surface smoke每个箭头都是独立状态边界。GitHub merge 不证明 npm artifact 已存在,npm publish 不证明任何 VM 已升级,VM command 返回成功也不替代 post-rollout health 与实际 chat/integration surface 验收。
npm package 的 Trusted Publisher 必须配置为:
- Provider:
GitHub Actions - Organization or user:
workwright - Repository:
platform - Workflow filename:
publish-runtime-cli.yml - Allowed action:
npm publish - Environment: 留空
该 workflow 使用 GitHub OIDC 的短期凭证,不需要 repository secret 或长期 NPM_TOKEN。完成 npm 侧配置后,维护者按以下顺序发布新版本:
# 先通过 PR 更新 package.json version,并等待 main CI 通过。
git switch main
git pull --ff-only origin main
test "$(node -p "require('./packages/runtime-cli/package.json').version")" = "0.2.0"
git tag -a runtime-cli-v0.2.0 -m "release(runtime-cli): 0.2.0"
git push origin runtime-cli-v0.2.0tag workflow 成功后,先从 registry 独立确认 exact artifact,再逐台 rollout:
test "$(npm view @workwright/[email protected] version --registry=https://registry.npmjs.org/)" = "0.2.0"
workwright plan --release 0.2.0 --botmux 3.17.0
workwright upgrade --release 0.2.0 --botmux 3.17.0
workwright status --json
workwright doctorplan 必须先证明 workload idle、target/rollback artifacts 和 runtime contract;upgrade 才进入 maintenance transaction。升级后还要验证 systemd/dashboard 以及实际 Lark、Codex 或其它已启用 surface。多台 VM 当前逐台执行同一 exact 流程;本 CLI 不提供 fleet canary/orchestration。
npm publish 成功只证明 registry artifact 已发布。若 Trusted Publisher 尚未配置或 identity 不匹配,publish job 会失败,不得改用长期 bypass-2FA token 绕过。
开发验证
npm test
npm run check
npm pack --dry-run
# Ubuntu CI opt-in registry integration; installs exact Botmux only in private temp prefixes.
WORKWRIGHT_REAL_BOTMUX_INTEGRATION=1 npm run test:real-botmux