From 48244331beb961631bcbdc799ab7dc68e57c56ec Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Mon, 13 Jul 2026 18:32:24 +0800 Subject: [PATCH 1/4] docs(sandbox): add csghub local codex deployment plan --- .../csghub-local-codex-cluster-plan.zh.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/sandbox/csghub-local-codex-cluster-plan.zh.md diff --git a/docs/sandbox/csghub-local-codex-cluster-plan.zh.md b/docs/sandbox/csghub-local-codex-cluster-plan.zh.md new file mode 100644 index 00000000..2721b282 --- /dev/null +++ b/docs/sandbox/csghub-local-codex-cluster-plan.zh.md @@ -0,0 +1,140 @@ +# CSGHub CSGClaw server 镜像启用本地 Codex:最小改造 + +## 目标与结论 + +目标是在 CSGHub 创建的 **一个 `csgclaw-server` 容器**中运行: + +```text +csgclaw serve +├─ Codex manager app-server +└─ Codex worker app-server(s) +``` + +实现改动集中在 `sandbox-runtime` 的四个发布相关文件:Dockerfile、默认 worker +模板配置、构建默认基座版本和镜像发布 tag。**不修改 CSGClaw Go 代码、CSGBot、 +entrypoint、hub 模板或 `csgclaw-agent` 镜像。** + +理由是 CSGClaw 的 `codex` 已是本地 runtime:它在 CSGClaw 所在环境直接执行 +`codex app-server --listen stdio://`。因此 CSGHub server sandbox 只要具备相同二进制 +和默认模板,运行行为与个人电脑部署一致。`runtime_kind=codex` 不会进入 +sandbox gateway,也不会为 manager/worker 调 CSGHub `Create`。 + +旧 PicoClaw/OpenClaw sandbox capability 保留,但不是这个 server 镜像的默认 +manager/worker 路径。 + +## 改动清单 + +### 1. `csgclaw-server/Dockerfile` + +只做三项: + +1. 将 `CSGCLAW_BASE_IMAGE` 更新为 + `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw:v0.3.18`。 +2. 在镜像构建期用多阶段构建安装**固定版本**的 Linux Codex CLI: + - 写入 `/usr/local/bin/codex`; + - 固定 npm 包 `@openai/codex@0.144.1`,按 `TARGETARCH` 复制其 musl 原生 + 二进制; + - 设置 `CSGCLAW_CODEX_PATH=/usr/local/bin/codex`(或确保该路径在 `PATH`); + - 构建期运行 `codex --version`。 +3. 将基座中直接位于 `/usr/local/bin` 的 `csgclaw` / `csgclaw-cli` 规范化为 + `/opt/csgclaw/bin/...` 的官方 bundle 布局,并保留原命令路径的软链。 + `v0.3.18` 基座本身没有这个布局,而正式版本的 `csgclaw serve` 会校验它;不做这 + 一步会在启动前直接退出,与 Codex 无关。 + +构建期的 npm 和 Alpine 下载源均是可覆盖的 Docker `ARG`;仅影响构建下载速度, +不改变容器运行时的软件源或 CSGClaw 行为。 + +不要依赖 `csgclaw serve` 的运行期自动下载;它失败只会告警,可能造成 HTTP server +健康而 Codex manager 不可用。 + +以下 Dockerfile 内容不改: + +- UID/GID 1000 和 `/home/picoclaw/.csgclaw` PVC 布局; +- supervisor、tini、Python sandbox :8888; +- `CSGCLAW_AGENT_TEMPLATE_IMAGE`、hub 模板的 image 替换; +- `csgclaw-agent` 相关构建能力。 + +### 2. `csgclaw-server/config.toml`(仅控制默认新 worker) + +只替换 worker 默认模板: + +```toml +[bootstrap] +default_worker_template = "builtin.codex-worker" +``` + +不需要修改 `default_manager_template`。实际 manager 在 +`internal/agent/service.go:465-488` 中固定为 Codex,不从这个配置选择 runtime。 + +`default_worker_template` 也不改变已有 worker,更不改变用户显式选择的模板。它只在 +创建请求没有 `from_template` 时被 `templateRefForCreateSpec` 作为 worker 默认模板 +(`internal/agent/service.go:1028-1045`)使用。当前 server 镜像写的是 +`local/picoclaw-worker`,所以普通“新建 worker”会默认走 PicoClaw sandbox;改成 +`builtin.codex-worker` 才使这个默认操作走容器内 Codex。 + +若前端/API 始终显式传 `from_template = "builtin.codex-worker"`,则连这一个 +`config.toml` 改动也不需要。 + +其余配置行保持原样,尤其是: + +```toml +[server] +advertise_base_url = "${CSGCLAW_ADVERTISE_BASE_URL}" + +[sandbox] +provider = "csghub" +``` + +不能将 `advertise_base_url` 固定为 `127.0.0.1`。当前 CSGClaw 同时用它: + +- 给 Codex profile 构造 CSGClaw LLM bridge URL; +- 给独立 sandbox 中的 OpenClaw/PicoClaw 生成 `CSGCLAW_BASE_URL` 回调地址。 + +OpenClaw/PicoClaw 若在另一个容器,`127.0.0.1` 是它自己的 loopback,无法回调 +server。最小方案沿用 CSGBot 注入的外部可达 Gateway URL;Codex 也通过这个已有地址 +访问 CSGClaw LLM bridge。 + +### 3. 明确不改 `docker-entrypoint.sh` + +entrypoint 当前每次启动都会把镜像内的 `config.toml` 和 `hub/` 复制进 PVC,并要求 +现有的模型/CSGHub 环境变量。最小方案不改变这些行为。 + +这也意味着:必须修改并发布镜像内的 `config.toml`;在运行中手改 PVC 的 config 会被 +下一次启动覆盖。 + +### 4. 构建与发布默认值 + +`Makefile` 中的 `CSGCLAW_BASE_IMAGE_NAME` 同步为 `v0.3.18`,避免本地 `make` +仍构建旧基座;`.ci/images/csgclaw-server-sandbox.env` 将待发布镜像标为: + +```text +opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsg_public/csgclaw-server-sandbox:20260713-dev +``` + +这两个文件不改变线上运行时逻辑,只使默认构建和 CI 发布指向本次镜像。 + +## 不修改的仓库和文件 + +| 路径/仓库 | 原因 | +| --- | --- | +| `csgclaw/` | 本地 Codex runtime、session/bridge 和 CSGHub gateway provider 都已具备。 | +| `csgbot/` | 继续创建外层 server sandbox,并继续提供当前的环境变量、PVC、健康检查和旧 sandbox 配置。 | +| `csgclaw-server/docker-entrypoint.sh` | 当前配置复制和环境变量校验可继续使用。 | +| `csgclaw-server/hub/` | 默认路径改为 builtin Codex template,不需要修改 local hub。 | +| `csgclaw-agent/` | 保留旧 OpenClaw/PicoClaw sandbox capability;默认 Codex 路径不会启动它。 | + +## 验收 + +1. 构建新 server 镜像;以 `--entrypoint /usr/local/bin/codex` 运行 + `codex --version`,确认 CLI 在最终镜像可执行。 +2. 使用现有 CSGBot 创建 CSGClaw server sandbox,确认容器用户可写 + `/home/picoclaw/.csgclaw`。 +3. 确认 manager 为 `runtime_kind=codex`;未显式选择模板的新建 worker 也为 + `runtime_kind=codex`(若未改 worker 默认模板,则显式选择 `builtin.codex-worker`)。 +4. 观察 CSGHub 审计:创建上述 manager/worker 时不应产生新的 sandbox `Create`; + 仅有 CSGBot 创建的外层 server sandbox。 +5. 发送一次真实消息,确认 Codex 能通过现有 + `CSGCLAW_ADVERTISE_BASE_URL` 到达 CSGClaw LLM bridge 并完成响应。 + +若第 5 步失败,先检查镜像中 Codex 的 stderr、AI Gateway 的 Responses 接口和 +`CSGCLAW_ADVERTISE_BASE_URL` 的容器可达性;这些不是 CSGClaw runtime 重写问题。 From edc7dda5e80eb311ea8325d14d0061e0685a72d8 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Tue, 14 Jul 2026 09:59:49 +0800 Subject: [PATCH 2/4] docs(sandbox): clarify codex image installation --- .../csghub-local-codex-cluster-plan.zh.md | 74 +++++++++++++------ 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/docs/sandbox/csghub-local-codex-cluster-plan.zh.md b/docs/sandbox/csghub-local-codex-cluster-plan.zh.md index 2721b282..1b577c32 100644 --- a/docs/sandbox/csghub-local-codex-cluster-plan.zh.md +++ b/docs/sandbox/csghub-local-codex-cluster-plan.zh.md @@ -10,9 +10,10 @@ csgclaw serve └─ Codex worker app-server(s) ``` -实现改动集中在 `sandbox-runtime` 的四个发布相关文件:Dockerfile、默认 worker -模板配置、构建默认基座版本和镜像发布 tag。**不修改 CSGClaw Go 代码、CSGBot、 -entrypoint、hub 模板或 `csgclaw-agent` 镜像。** +实现先要完成两项**必需的镜像改动**:在 `csgclaw-server` 最终镜像中安装可执行的 +Codex CLI,以及把未指定模板的新 worker 默认切到内建 Codex。然后才是选择 base image +和发布 server wrapper tag。CSGClaw Go 代码和 CSGBot 不需要重写,是因为上述镜像和默认 +模板就能接入已有 runtime;它们不是本方案的改动主体。 理由是 CSGClaw 的 `codex` 已是本地 runtime:它在 CSGClaw 所在环境直接执行 `codex app-server --listen stdio://`。因此 CSGHub server sandbox 只要具备相同二进制 @@ -26,28 +27,42 @@ manager/worker 路径。 ### 1. `csgclaw-server/Dockerfile` -只做三项: +这是本方案的核心改动。最终 server 镜像必须在**构建期**得到 Codex 原生二进制,不能把 +安装留到容器启动后。 1. 将 `CSGCLAW_BASE_IMAGE` 更新为 `opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw:v0.3.18`。 -2. 在镜像构建期用多阶段构建安装**固定版本**的 Linux Codex CLI: - - 写入 `/usr/local/bin/codex`; - - 固定 npm 包 `@openai/codex@0.144.1`,按 `TARGETARCH` 复制其 musl 原生 - 二进制; - - 设置 `CSGCLAW_CODEX_PATH=/usr/local/bin/codex`(或确保该路径在 `PATH`); - - 构建期运行 `codex --version`。 -3. 将基座中直接位于 `/usr/local/bin` 的 `csgclaw` / `csgclaw-cli` 规范化为 +2. 在 Dockerfile 顶部固定下载根地址: + + ```dockerfile + ARG CSGCLAW_CODEX_DOWNLOAD_BASE_URL=https://csgclaw.opencsg.com/codex-cli/latest + ``` + + 在 `FROM ${CSGCLAW_BASE_IMAGE}` 后为当前 stage 重新声明该 `ARG`(Docker 的作用域 + 要求),安装 `curl`,并按 `TARGETARCH` 下载: + + ```text + https://csgclaw.opencsg.com/codex-cli/latest/linux/?package=codex-cli + ``` + + 下载的是包含 musl 原生二进制的 tar.gz。构建脚本必须:映射 `amd64`/`arm64` 到 archive + 中的对应文件名、解包、以 `0755` 安装到 `/opt/codex/bin/codex`、软链到 + `/usr/local/bin/codex`,最后执行 `codex --version`。这一步失败必须让 image build + 失败。 + + 该 URL 只作为 Dockerfile build arg 的默认值;**不**通过 Makefile、CI pipeline variable + 或最终容器的环境变量暴露。每次构建取得当前 `latest`,因此并不承诺固定 Codex 版本。 +3. 设置 `CSGCLAW_CODEX_PATH=/usr/local/bin/codex`,使 CSGClaw 在启动时优先使用镜像内 + CLI,不触发运行期自动安装。 +4. 将基座中直接位于 `/usr/local/bin` 的 `csgclaw` / `csgclaw-cli` 规范化为 `/opt/csgclaw/bin/...` 的官方 bundle 布局,并保留原命令路径的软链。 `v0.3.18` 基座本身没有这个布局,而正式版本的 `csgclaw serve` 会校验它;不做这 一步会在启动前直接退出,与 Codex 无关。 -构建期的 npm 和 Alpine 下载源均是可覆盖的 Docker `ARG`;仅影响构建下载速度, -不改变容器运行时的软件源或 CSGClaw 行为。 - 不要依赖 `csgclaw serve` 的运行期自动下载;它失败只会告警,可能造成 HTTP server 健康而 Codex manager 不可用。 -以下 Dockerfile 内容不改: +这次 Dockerfile 改动不触及以下兼容层: - UID/GID 1000 和 `/home/picoclaw/.csgclaw` PVC 布局; - supervisor、tini、Python sandbox :8888; @@ -94,7 +109,7 @@ OpenClaw/PicoClaw 若在另一个容器,`127.0.0.1` 是它自己的 loopback server。最小方案沿用 CSGBot 注入的外部可达 Gateway URL;Codex 也通过这个已有地址 访问 CSGClaw LLM bridge。 -### 3. 明确不改 `docker-entrypoint.sh` +### 3. 保持 `docker-entrypoint.sh` 的启动契约 entrypoint 当前每次启动都会把镜像内的 `config.toml` 和 `hub/` 复制进 PVC,并要求 现有的模型/CSGHub 环境变量。最小方案不改变这些行为。 @@ -102,18 +117,26 @@ entrypoint 当前每次启动都会把镜像内的 `config.toml` 和 `hub/` 复 这也意味着:必须修改并发布镜像内的 `config.toml`;在运行中手改 PVC 的 config 会被 下一次启动覆盖。 -### 4. 构建与发布默认值 +### 4. 构建与发布 + +`Makefile` 的 `CSGCLAW_BASE_IMAGE_NAME` 默认值为 `v0.3.18`,并把它传给 Dockerfile +的 `CSGCLAW_BASE_IMAGE`。Codex 下载地址不在 Makefile 中配置,始终使用 Dockerfile +顶部的默认 URL。 -`Makefile` 中的 `CSGCLAW_BASE_IMAGE_NAME` 同步为 `v0.3.18`,避免本地 `make` -仍构建旧基座;`.ci/images/csgclaw-server-sandbox.env` 将待发布镜像标为: +GitLab 的 `image:csgclaw-server-sandbox` 是普通 `.ci/images/*.env` 机制的例外:创建 +protected `main` pipeline 时必须传入: ```text -opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsg_public/csgclaw-server-sandbox:20260713-dev +CSGCLAW_BASE_IMAGE_NAME=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/csgclaw: +CSGCLAW_SERVER_SANDBOX_IMAGE_NAME=opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsg_public/csgclaw-server-sandbox: ``` -这两个文件不改变线上运行时逻辑,只使默认构建和 CI 发布指向本次镜像。 +CI 从已提交的 `csgclaw-agent-sandbox.env` 读取兼容模板使用的 agent image;调用者不需 +传第三个变量。构建成功只会推送 server wrapper image;仍须在目标环境的 CSGBot 配置中 +将 `[sandbox.csgclaw-server].image` 更新为新 tag 并部署,之后新创建的 sandbox 才会采用 +新镜像。 -## 不修改的仓库和文件 +## 实施范围外(在上述改动完成后保持不变) | 路径/仓库 | 原因 | | --- | --- | @@ -125,8 +148,11 @@ opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsg_public/csgclaw-server-sandbo ## 验收 -1. 构建新 server 镜像;以 `--entrypoint /usr/local/bin/codex` 运行 - `codex --version`,确认 CLI 在最终镜像可执行。 +1. 使用无缓存构建新 server 镜像,确认日志从 + `https://csgclaw.opencsg.com/codex-cli/latest/linux/?package=codex-cli` 下载 + archive、打印 `codex --version`,且不再出现 Node/npm 或 Docker Hub 的 Codex 安装步骤; + 再以 `--entrypoint /usr/local/bin/codex` 运行 `codex --version`,确认 CLI 在最终镜像 + 可执行。 2. 使用现有 CSGBot 创建 CSGClaw server sandbox,确认容器用户可写 `/home/picoclaw/.csgclaw`。 3. 确认 manager 为 `runtime_kind=codex`;未显式选择模板的新建 worker 也为 From 119ebce003039842d0a79a8d29ec6a3b9af65da8 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Tue, 14 Jul 2026 16:52:19 +0800 Subject: [PATCH 3/4] feat(upgrade): restart supervisor-managed server after upgrade --- cli/upgrade/upgrade.go | 10 + cli/upgrade/upgrade_test.go | 74 ++++ .../csghub-supervised-upgrade-plan.zh.md | 386 ++++++++++++++++++ internal/upgrade/restart_supervisor.go | 46 +++ internal/upgrade/restart_supervisor_test.go | 97 +++++ 5 files changed, 613 insertions(+) create mode 100644 docs/sandbox/csghub-supervised-upgrade-plan.zh.md create mode 100644 internal/upgrade/restart_supervisor.go create mode 100644 internal/upgrade/restart_supervisor_test.go diff --git a/cli/upgrade/upgrade.go b/cli/upgrade/upgrade.go index 09db3cc7..c9b617b0 100644 --- a/cli/upgrade/upgrade.go +++ b/cli/upgrade/upgrade.go @@ -32,6 +32,7 @@ var ( stopDaemonFromExecutable = upgrade.StopDaemonFromExecutable startDaemonFromExecutable = upgrade.StartDaemonFromExecutable startInstalledDaemon = upgrade.StartInstalledDaemon + restartSupervisorParent = upgrade.RestartSupervisorParentIfConfigured ) func NewCmd() command.Command { @@ -131,6 +132,15 @@ func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globa if err != nil { return fail(err) } + if !restarted.DaemonWasRunning { + supervised, configured, restartErr := restartSupervisorParent() + if restartErr != nil { + return fail(restartErr) + } + if configured { + restarted = supervised + } + } } if !*noRestart && !restarted.DaemonWasRunning { if recordErr := applyArtifacts.RecordManualRestartRequired("manual restart required"); recordErr != nil { diff --git a/cli/upgrade/upgrade_test.go b/cli/upgrade/upgrade_test.go index 49e53062..a8543e37 100644 --- a/cli/upgrade/upgrade_test.go +++ b/cli/upgrade/upgrade_test.go @@ -90,6 +90,80 @@ func TestRunNoRestartInstallsBundle(t *testing.T) { assertFileContent(t, filepath.Join(installRoot, "README.md"), "new") } +func TestRunUsesSupervisorParentRestartWhenNoDaemonPIDExists(t *testing.T) { + originalVersion := appversion.Version + appversion.Version = "v0.2.5" + t.Cleanup(func() { appversion.Version = originalVersion }) + + originalGOOS := currentGOOS + currentGOOS = "darwin" + t.Cleanup(func() { currentGOOS = originalGOOS }) + t.Setenv("HOME", t.TempDir()) + + originalRestartSupervisorParent := restartSupervisorParent + var restartRequested bool + restartSupervisorParent = func() (internalupgrade.RestartResult, bool, error) { + restartRequested = true + return internalupgrade.RestartResult{DaemonWasRunning: true, Restarted: true}, true, nil + } + t.Cleanup(func() { restartSupervisorParent = originalRestartSupervisorParent }) + + installRoot := writeInstalledBundle(t, t.TempDir(), "old") + archive := releaseTarball(t, map[string]string{ + "csgclaw/bin/csgclaw": "#!/bin/sh\n# new\n", + "csgclaw/bin/boxlite": "#!/bin/sh\n# new boxlite\n", + }) + sum := sha256.Sum256(archive) + + originalClientFactory := newUpgradeClient + newUpgradeClient = func(run *command.Context) internalupgrade.Client { + return internalupgrade.Client{ + HTTPClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case "https://example.test/releases/latest": + return jsonResponse(http.StatusOK, `{ + "name":"v0.2.7", + "assets":[{"name":"csgclaw_v0.2.7_darwin_arm64.tar.gz","browser_download_url":"https://downloads.example.test/csgclaw.tar.gz","size":`+strconv.Itoa(len(archive))+`,"sha256":"`+hex.EncodeToString(sum[:])+`"}] + }`), nil + case "https://downloads.example.test/csgclaw.tar.gz": + return &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(archive)), + }, nil + default: + t.Fatalf("unexpected URL %q", req.URL.String()) + return nil, nil + } + }), + LatestURL: "https://example.test/releases/latest", + GOOS: "darwin", + GOARCH: "arm64", + ExecutablePath: func() (string, error) { + return filepath.Join(installRoot, "bin", "csgclaw"), nil + }, + } + } + t.Cleanup(func() { newUpgradeClient = originalClientFactory }) + + var stdout bytes.Buffer + err := NewCmd().Run(context.Background(), &command.Context{ + Program: "csgclaw", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + }, nil, command.GlobalOptions{Output: "table"}) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !restartRequested { + t.Fatal("supervisor parent restart was not requested") + } + if !strings.Contains(stdout.String(), "Restarting service") { + t.Fatalf("stdout = %q, want restart output", stdout.String()) + } +} + func TestRunWindowsNoRestartDoesNotStopDaemon(t *testing.T) { originalVersion := appversion.Version appversion.Version = "v0.2.5" diff --git a/docs/sandbox/csghub-supervised-upgrade-plan.zh.md b/docs/sandbox/csghub-supervised-upgrade-plan.zh.md new file mode 100644 index 00000000..9ebbab7b --- /dev/null +++ b/docs/sandbox/csghub-supervised-upgrade-plan.zh.md @@ -0,0 +1,386 @@ +# CSGHub Sandbox 中 CSGClaw 的界面原地升级方案 + +## 状态 + +提案。本文定义 CSGHub `csgclaw-server-sandbox` 中由 Supervisor 托管的 +CSGClaw 如何支持与个人电脑安装相同的“在 UI 中检查、下载、安装并重启”体验。 +实现横跨 `csgclaw`、`sandbox-runtime` 和 CSGBot;本文不改变本地 BoxLite +或普通官方 bundle 的升级语义。 + +## 1. 目标与边界 + +用户在 CSGClaw Web UI 点击升级后,应能完成: + +1. 检查并展示最新 CSGClaw release; +2. 下载与当前 Linux 架构匹配的官方 bundle; +3. 原子替换该 sandbox 自己的 CSGClaw bundle; +4. 由 Supervisor 拉起新 binary; +5. 浏览器短暂断开后重新连接,并明确显示成功或失败; +6. sandbox pod 重启后仍运行已升级的版本。 + +不在本方案范围内: + +- 用 UI 更新 `csgclaw-server-sandbox` wrapper 镜像、Python sandbox、Codex + CLI 或 agent sandbox 镜像;这些仍由镜像发布流程管理。 +- 在同一 sandbox 之间共享升级结果;每个 CSGHub sandbox 的版本和 bundle + 独立。 +- 为任意进程管理器执行用户给出的 shell command。升级代码不得接受任意 + `restart_command` 或可注入的命令字符串。 + +## 2. 当前实现与不能直接复用的原因 + +当前 server wrapper 的实际路径如下: + +```text +/usr/local/bin/csgclaw + -> /opt/csgclaw/bin/csgclaw + +Supervisor + -> /usr/local/bin/csgclaw --config /home/picoclaw/.csgclaw/config.toml serve +``` + +Dockerfile 通过 marker 把 `/opt/csgclaw` 伪装成官方 bundle,以满足 release +build 的启动校验;Supervisor 则以前台 `serve` 管理该进程。 + +这使得现有 `csgclaw upgrade` 不能安全工作: + +- 自动安装会在 bundle 的父目录创建同级 staging 与 backup。运行用户是 UID + 1000,而 `/opt` 不是该用户可写目录,安装会在创建 staging 时失败。 +- 当前重启逻辑只读取 `~/.csgclaw/server.pid` 并执行 `stop` / `serve --daemon`。 + Supervisor 的前台服务没有该 PID 文件,因此会被视为“未运行,需要手动 + 重启”。 +- 容器根文件系统是临时的。即使放宽 `/opt` 权限并在运行中替换成功,pod + 重建后仍会回到镜像内的旧 binary。 +- server wrapper 当前 `show_upgrade = false`。该设置应继续是默认值,直到 + 本文定义的持久 bundle 与受控重启都已实现。 + +因此不能只把 `show_upgrade` 改为 `true`,也不应通过 `chmod /opt` 让当前 +bundle 可写。 + +## 3. 目标架构 + +每个 server sandbox 已有一个独立 PVC subpath,挂载到 +`/home/picoclaw/.csgclaw`。该卷同时承载配置、Hub 内容和 upgrade 状态;新增 +bundle 目录后,布局为: + +```text +镜像(只读) + /opt/csgclaw-bundle-seed/csgclaw/ + .csgclaw-bundle.json + bin/csgclaw + bin/csgclaw_dir/csgclaw-cli + +每个 sandbox 的 PVC(可写、持久) + /home/picoclaw/.csgclaw/ + config.toml + hub/ + bundle/ + csgclaw/ # 当前运行 bundle + csgclaw.backup./ # upgrade 期间保留 + logs/ + upgrade-helper.log + upgrade-helper-status.json + +固定 launcher + /usr/local/bin/csgclaw + -> /home/picoclaw/.csgclaw/bundle/csgclaw/bin/csgclaw + /usr/local/bin/csgclaw-cli + -> /home/picoclaw/.csgclaw/bundle/csgclaw/bin/csgclaw_dir/csgclaw-cli +``` + +`/usr/local/bin` 中的 symlink 可以在镜像构建时创建,即使 PVC target 尚未 +存在。entrypoint 在启动 Supervisor 前保证 target 已存在且通过 bundle 结构 +校验。 + +```text +UI POST /api/v1/upgrade/apply + | + v +旧 csgclaw server --spawn--> upgrade helper + | | + | 下载、校验、替换 PVC bundle + | | + |<----- SIGTERM ---------+ (只在成功安装后) + v +Supervisor 发现前台 server 退出 + | + v +同一路径重新 exec,symlink 已指向 PVC 中的新 bundle + | + v +新 server 健康检查成功,UI 重新连接并显示结果 +``` + +## 4. `sandbox-runtime` 修改 + +### 4.1 Dockerfile:只读 seed,不再以 `/opt/csgclaw` 作为运行安装根 + +修改 `csgclaw-server/Dockerfile`: + +1. 将 base image 中的 `csgclaw` 和 `csgclaw-cli` 移到 + `/opt/csgclaw-bundle-seed/csgclaw/`,并写入正式 marker: + `{"app":"csgclaw","layout":"official-bundle"}`。 +2. 创建 `/usr/local/bin/csgclaw` 与 `/usr/local/bin/csgclaw-cli` 到 PVC + bundle target 的 symlink,不再指向 `/opt/csgclaw`。 +3. 保留 seed 为 root 所有、只读;UID 1000 只需要写 PVC 中的 + `bundle/`,不需要写 `/opt`。 +4. 删除或改正所有将 `/opt/csgclaw` 称为实际运行 bundle 的注释;若兼容 + 旧脚本仍需要该路径,使用只读 symlink 指向 seed,不能指向可变 bundle。 + +这样 `internal/upgrade.installBundle` 使用的同级 staging、backup 和 rename +均发生在可写的: + +```text +/home/picoclaw/.csgclaw/bundle/ +``` + +而不是扩大 `/opt` 的写权限。后者会使同一容器中的 UID 1000 可以替换 +`/opt/codex` 等无关路径。 + +### 4.2 entrypoint:仅首次 seed,绝不覆盖已升级版本 + +修改 `csgclaw-server/docker-entrypoint.sh`,在复制 `config.toml` 和 Hub +目录后、启动 Supervisor 前执行: + +1. 设定 `BUNDLE_PARENT="$CFG_DIR/bundle"`、 + `BUNDLE_ROOT="$BUNDLE_PARENT/csgclaw"`。 +2. 若 `BUNDLE_ROOT` 不存在,复制 seed 到同一父目录的临时目录,校验 marker + 和 `bin/csgclaw`,再 rename 为 `BUNDLE_ROOT`。 +3. 若目录存在,验证 marker 和 executable;验证失败则启动失败,不自动用新 + seed 覆盖。自动覆盖会掩盖损坏并丢失用户已升级的版本。 +4. 以 UID/GID 1000 确保 `bundle/` 可写;失败时在 Supervisor 启动前明确报错。 + +当前 entrypoint 在每次启动时重写 `config.toml` 与 `hub/`,但不会删除 +`bundle/`。新目录与已有的 PVC 生命周期兼容,不需要改变 CSGBot 的 volume +mount 路径。 + +### 4.3 Supervisor:显式声明受托管升级模式 + +在 `[program:csgclaw-server]` 的 environment 中加入: + +```ini +CSGCLAW_UPGRADE_RESTART_MODE="supervisor-parent" +``` + +该值仅供 CSGClaw server 及它启动的 upgrade helper 继承;不作为容器级别的 +自由命令配置。现有 `autorestart=true`、`stopasgroup=true` 和 +`killasgroup=true` 保持不变。 + +不要让 helper 直接运行 `supervisorctl restart csgclaw-server`。Supervisor +按进程组停止服务时可能同时终止作为 server 子进程的 helper,造成状态文件和 +回滚流程无法收尾。 + +### 4.4 UI 开关 + +在第 5 节的 CSGClaw 能力、完整性校验与端到端测试全部落地前,保持: + +```toml +[server] +show_upgrade = false +``` + +落地后将 server wrapper 的配置改为 `show_upgrade = true`。这只决定 UI +可见性;API 端仍必须独立做能力与授权校验。 + +## 5. `csgclaw` 修改 + +### 5.1 受控重启策略 + +在 `internal/upgrade` 增加显式的 restart strategy,而不是让 CLI 猜测 +Supervisor 或执行任意 shell: + +```go +type RestartMode string + +const ( + RestartModeDaemon RestartMode = "daemon" + RestartModeSupervisorParent RestartMode = "supervisor-parent" +) +``` + +默认仍是 `daemon`,完全保持个人电脑现有语义。只有 API helper 以 +`supervisor-parent` 模式启动时,才允许以下流程: + +1. `StartApplyHelper` 读取当前 server 的 PID(`os.Getpid()`),并将它作为 + `CSGCLAW_UPGRADE_RESTART_PARENT_PID` 传给 helper;同时传递经过验证的 mode。 +2. helper 完成下载、校验、原子替换和 runtime asset 同步后,才向该 PID 发送 + `SIGTERM`。 +3. 前台 server 已注册 `SIGTERM`,会正常退出;Supervisor 的 + `autorestart=true` 再以固定 launcher 路径启动新版本。 + +`--no-restart` 必须跳过这一步。直接在容器 shell 中执行 `csgclaw upgrade` +时没有由 API 传入的 parent PID,也必须退回“安装完成,需要人工重启”,而不能 +向任意父进程发信号。 + +实现文件建议: + +- `internal/upgrade/helper.go`:传递受控 mode 与 parent PID; +- `internal/upgrade/restart.go`:抽取 restart strategy; +- `internal/upgrade/restart_supervisor_unix.go`:仅 Unix 的 parent PID + 校验与 `SIGTERM`;Windows 不编译该实现; +- `cli/upgrade/upgrade.go`:在安装成功后调用 strategy,并分别呈现 + `restarted`、`restart_requested` 和 `manual_restart_required`; +- `internal/api/handler.go`:只允许被授权且具备该策略的 apply 请求。 + +`RestartResult` 应新增 `RestartRequested`,不能把“已向 Supervisor 请求 +重启”错误显示为“新服务已确认重启”。 + +### 5.2 持久升级事务与回滚 + +当前 `installBundle` 成功 rename 后会立刻删除 backup。对可人工重新部署的 +PC 安装可接受,但 sandbox UI 升级需要能处理新 binary 无法启动的情况。 + +本方案要求将安装拆为两个阶段: + +1. **prepare/activate**:复制到 staging,将旧 bundle rename 为 backup, + 将新 bundle rename 为 current;将持久事务记录写到 PVC。 +2. **finalize**:新 server 通过本地 `/healthz` 后删除 backup 和事务记录。 + +helper 在向旧 server 发 `SIGTERM` 后保持运行,使用 localhost health check +等待新版服务。成功时写入 `completed`;超时或启动失败时写入可读错误及 backup +路径。失败的自动回滚需要作为同一实现的一部分:恢复 backup 后请求 Supervisor +再次启动,并在最终状态中标记 `rolled_back`。若无法证明回滚已成功,保留 backup +并将状态标为 `manual_recovery_required`,绝不能静默删除。 + +状态文件应放在现有的 `~/.csgclaw/logs/`,并至少包含: + +```json +{ + "status": "restarting|completed|failed|rolled_back|manual_recovery_required", + "from_version": "vX.Y.Z", + "to_version": "vA.B.C", + "backup_path": "...", + "message": "...", + "updated_at": "..." +} +``` + +新 server 启动后消费该状态,将结果送回 `UpgradeManager` 与 +`upgrade.status_changed` SSE 事件。浏览器应把服务断开视为预期,并轮询 +`/healthz` / `/api/v1/upgrade/status` 直到完成或超时。 + +### 5.3 API 权限与能力校验 + +`POST /api/v1/upgrade/apply` 不能只依赖 Web UI 是否显示。请求必须同时满足: + +1. `show_upgrade` 已启用; +2. 当前运行的是已验证 marker 的持久 PVC bundle; +3. restart mode 为受支持的 `supervisor-parent` 或既有 `daemon`; +4. 外层 CSGHub gateway 已完成租户授权,且 CSGClaw access-token 语义没有 + 被 `no_auth` 绕过; +5. 当前没有正在执行的 upgrade transaction。 + +不满足时返回明确的 `409 Conflict` / `503 Service Unavailable`,并在 UI 中 +显示“当前部署不支持原地升级”,而不是启动一个必定失败的 helper。 + +### 5.4 发布物完整性是上线前置条件 + +当前下载逻辑只校验发布元数据中的文件大小,SHA-256 校验仍处于临时禁用状态。 +在将升级按钮暴露给多租户 sandbox 前,必须: + +1. 让 release 元数据为每个 bundle 发布 SHA-256; +2. 恢复 `PrepareRelease` 对缺失 hash 的拒绝; +3. 恢复流式下载中的 SHA-256 计算与比较; +4. 为 hash 缺失、hash 不匹配、size 不匹配和恶意 archive 分别添加测试。 + +如果发布链路允许,后续应改用签名 manifest;但 SHA-256 元数据校验是本方案 +最小可接受的上线条件。 + +## 6. CSGBot 影响 + +CSGBot 目前已经把每个 CSGClaw server sandbox 的独立 PVC subpath 挂载到 +`/home/picoclaw/.csgclaw`。因此本方案不需要为自升级新增卷或修改 volume +subpath 算法。 + +需要确认并补测试的契约是: + +- 同一 sandbox 的 stop/start 或 pod 重建复用同一 PVC subpath; +- 真正的 sandbox recreate 使用新 subpath,因此会从 wrapper image 的 seed + 开始; +- CSGClaw server wrapper 新版本不会覆盖已存在的 `bundle/csgclaw`; +- CSGBot 通过 `[sandbox.csgclaw-server].image` 切换 wrapper 镜像的既有发布 + 流程保持不变。 + +这意味着首次启用该能力时,已有 sandbox 需要重建一次以获得新的 wrapper +entrypoint 和 Supervisor environment;之后 UI 升级无需重新发布 wrapper。 + +## 7. 分阶段实施 + +### Phase 0:发布链路准备 + +- 发布带稳定 SHA-256 元数据的 CSGClaw release; +- 恢复并验证 CSGClaw 下载完整性校验; +- 定义可用于测试的 release metadata endpoint 注入方式,生产默认仍固定到 + 官方 HTTPS endpoint。 + +### Phase 1:持久 bundle bootstrap + +- 修改 `sandbox-runtime/csgclaw-server/Dockerfile` 与 entrypoint; +- 在新 wrapper image 中验证首次 seed、pod 重启保留、损坏 bundle 拒绝启动; +- 保持 `show_upgrade = false`,此阶段不暴露 UI 操作。 + +### Phase 2:Supervisor restart strategy + +- 实现受控 parent-PID restart mode 与状态机; +- 完成普通 daemon 行为的回归测试,确保 PC 升级不受影响; +- 实现 upgrade 后健康确认、backup 保留与自动/人工恢复状态; +- 在集成镜像中验证旧 PID 退出、新 PID 启动、PVC bundle 版本变化。 + +### Phase 3:API/UI 开放 + +- API 对 `show_upgrade`、能力和互斥事务做服务端校验; +- 启用 wrapper 的 `show_upgrade = true`; +- UI 显示“下载中、重启中、已完成、已回滚、需要人工恢复”并处理短暂断线; +- 灰度至 staging sandbox,验证后再启用 production image。 + +## 8. 验证清单 + +### CSGClaw 单元与集成测试 + +- 普通官方 bundle 的 `upgrade`、`--check`、`--no-restart` 结果不变。 +- `supervisor-parent` mode 只有 API helper 能构造;缺 mode、缺 PID、PID 非法或 + `--no-restart` 都不发送信号。 +- 安装、asset 同步完成前不会发终止信号。 +- 新增 transaction 状态可被新 server 恢复;失败、回滚和人工恢复状态都能通过 + API/SSE 读取。 +- SHA-256 缺失、不匹配及 archive 路径穿越都不会替换 current bundle。 + +### sandbox-runtime 容器测试 + +- 使用空 PVC 启动时从 seed 创建 bundle,且 CSGClaw 可以启动。 +- 在 PVC 内安装测试 release 后,Supervisor 拉起新 PID,`--version` 与 + `/api/v1/version` 均为目标版本。 +- 重启 container/pod 并复用 PVC,版本仍为目标版本。 +- 用包含不同 seed 的新 wrapper 重启同一 PVC,不覆盖用户已升级的 bundle。 +- 新 binary 无法健康启动时,验证 backup、rollback 和 `manual_recovery_required` + 的最终状态。 +- UID 1000 不需要、也不能通过升级流程写入 `/opt/csgclaw-bundle-seed` 或 + `/opt/codex`。 + +### CSGBot 端到端测试 + +- 创建 CSGClaw sandbox 后确认其 PVC 挂载包含 `bundle/`。 +- 对同一 sandbox 调用 UI upgrade,验证 CSGHub gateway 在短暂重启后恢复。 +- sandbox recreate 后确认使用新 wrapper seed,而不是前一个 sandbox 的 bundle。 + +## 9. 上线与回退 + +上线前先发布带该能力、但保持 `show_upgrade = false` 的 wrapper image。仅在 +staging 上完成 Phase 1–2 验证后再打开 UI。 + +出现问题时可立即把 wrapper 的 `show_upgrade` 关闭;这会阻止新 transaction, +不修改已持久化 bundle。对于单个失败 sandbox,保留的 transaction record 与 +backup 是恢复依据。平台级回退仍可通过 CSGBot 切换回旧 +`csgclaw-server-sandbox` image 并重建 sandbox 完成。 + +## 10. 需要确认的产品决策 + +1. sandbox 用户是否有权自行选择任意公开 CSGClaw release,还是只能升级到 + 平台允许的版本范围? +2. 升级成功后是否应保留 backup 一段固定时间,还是完成 health check 即删除? +3. 新 wrapper image 与 PVC 中已升级 bundle 的版本不一致时,平台是否允许继续 + 运行 PVC 版本,还是要求由运维强制重置为 image seed? +4. 升级失败且自动回滚也失败时,平台控制面是否需要提供“重建 sandbox”按钮, + 还是只显示人工恢复说明? + +在以上决策确定前,不应把 `show_upgrade` 打开到 production。 diff --git a/internal/upgrade/restart_supervisor.go b/internal/upgrade/restart_supervisor.go new file mode 100644 index 00000000..78ec588d --- /dev/null +++ b/internal/upgrade/restart_supervisor.go @@ -0,0 +1,46 @@ +package upgrade + +import ( + "fmt" + "os" + "strings" +) + +const supervisorParentRestartModeEnv = "CSGCLAW_UPGRADE_RESTART_MODE" + +const supervisorParentRestartMode = "supervisor-parent" + +var ( + supervisorParentPID = os.Getppid + signalSupervisorParent = func(pid int) error { + proc, err := findProcessByPID(pid) + if err != nil { + return fmt.Errorf("find supervisor-managed server process %d: %w", pid, err) + } + if err := proc.Signal(os.Interrupt); err != nil { + return fmt.Errorf("signal supervisor-managed server process %d: %w", pid, err) + } + return nil + } +) + +// RestartSupervisorParentIfConfigured asks Supervisor to restart the current +// foreground server by stopping the helper's parent process. It is enabled +// only for helpers spawned by a Supervisor-managed server. +func RestartSupervisorParentIfConfigured() (RestartResult, bool, error) { + if strings.TrimSpace(os.Getenv(supervisorParentRestartModeEnv)) != supervisorParentRestartMode { + return RestartResult{}, false, nil + } + + pid := supervisorParentPID() + if pid <= 1 { + return RestartResult{}, true, fmt.Errorf("invalid supervisor-managed server parent pid %d", pid) + } + if err := signalSupervisorParent(pid); err != nil { + return RestartResult{}, true, err + } + return RestartResult{ + DaemonWasRunning: true, + Restarted: true, + }, true, nil +} diff --git a/internal/upgrade/restart_supervisor_test.go b/internal/upgrade/restart_supervisor_test.go new file mode 100644 index 00000000..4811a747 --- /dev/null +++ b/internal/upgrade/restart_supervisor_test.go @@ -0,0 +1,97 @@ +package upgrade + +import ( + "errors" + "os" + "testing" +) + +func TestRestartSupervisorParentIfConfigured(t *testing.T) { + originalPID := supervisorParentPID + originalSignal := signalSupervisorParent + t.Cleanup(func() { + supervisorParentPID = originalPID + signalSupervisorParent = originalSignal + }) + t.Setenv(supervisorParentRestartModeEnv, supervisorParentRestartMode) + + supervisorParentPID = func() int { return 4321 } + var gotPID int + signalSupervisorParent = func(pid int) error { + gotPID = pid + return nil + } + + got, configured, err := RestartSupervisorParentIfConfigured() + if err != nil { + t.Fatalf("RestartSupervisorParentIfConfigured() error = %v", err) + } + if !configured { + t.Fatal("configured = false, want true") + } + if gotPID != 4321 { + t.Fatalf("signaled pid = %d, want 4321", gotPID) + } + if !got.DaemonWasRunning || !got.Restarted { + t.Fatalf("result = %#v, want restart requested", got) + } +} + +func TestRestartSupervisorParentIfConfiguredSkipsOtherModes(t *testing.T) { + t.Setenv(supervisorParentRestartModeEnv, "") + got, configured, err := RestartSupervisorParentIfConfigured() + if err != nil { + t.Fatalf("RestartSupervisorParentIfConfigured() error = %v", err) + } + if configured { + t.Fatal("configured = true, want false") + } + if got != (RestartResult{}) { + t.Fatalf("result = %#v, want zero value", got) + } +} + +func TestRestartSupervisorParentIfConfiguredRejectsInvalidParent(t *testing.T) { + originalPID := supervisorParentPID + originalSignal := signalSupervisorParent + t.Cleanup(func() { + supervisorParentPID = originalPID + signalSupervisorParent = originalSignal + }) + t.Setenv(supervisorParentRestartModeEnv, supervisorParentRestartMode) + + supervisorParentPID = func() int { return 1 } + signalSupervisorParent = func(int) error { + t.Fatal("signalSupervisorParent should not be called") + return errors.New("unreachable") + } + + _, configured, err := RestartSupervisorParentIfConfigured() + if !configured { + t.Fatal("configured = false, want true") + } + if err == nil { + t.Fatal("RestartSupervisorParentIfConfigured() error = nil, want invalid parent error") + } +} + +func TestRestartSupervisorParentIfConfiguredReturnsSignalFailure(t *testing.T) { + originalPID := supervisorParentPID + originalSignal := signalSupervisorParent + t.Cleanup(func() { + supervisorParentPID = originalPID + signalSupervisorParent = originalSignal + }) + t.Setenv(supervisorParentRestartModeEnv, supervisorParentRestartMode) + + supervisorParentPID = func() int { return os.Getpid() } + signalSupervisorParent = func(int) error { return errors.New("signal failed") } + + _, configured, err := RestartSupervisorParentIfConfigured() + if !configured { + t.Fatal("configured = false, want true") + } + if err == nil || err.Error() != "signal failed" { + t.Fatalf("RestartSupervisorParentIfConfigured() error = %v, want signal failed", err) + } +} From 17a21ae8f643d40ad83c637ac583ab67a098e138 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Wed, 15 Jul 2026 10:12:30 +0800 Subject: [PATCH 4/4] feat(upgrade): add settings update action --- .../SettingsPage/SettingsPage.module.css | 4 + .../src/pages/SettingsPage/SettingsPage.tsx | 17 ++++- .../tests/components/SettingsPage.test.tsx | 75 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 web/app/tests/components/SettingsPage.test.tsx diff --git a/web/app/src/pages/SettingsPage/SettingsPage.module.css b/web/app/src/pages/SettingsPage/SettingsPage.module.css index 658aaee0..0b05e795 100644 --- a/web/app/src/pages/SettingsPage/SettingsPage.module.css +++ b/web/app/src/pages/SettingsPage/SettingsPage.module.css @@ -176,6 +176,10 @@ width: 100%; } +.versionValueWithAction { + grid-template-columns: minmax(0, 1fr) auto auto; +} + .settingLine, .environmentLine { min-height: 40px; diff --git a/web/app/src/pages/SettingsPage/SettingsPage.tsx b/web/app/src/pages/SettingsPage/SettingsPage.tsx index 3fc1cc08..6a7a436e 100644 --- a/web/app/src/pages/SettingsPage/SettingsPage.tsx +++ b/web/app/src/pages/SettingsPage/SettingsPage.tsx @@ -14,7 +14,7 @@ import { } from "@/models/authEnvironment"; import type { AuthEnvironmentDraft, AuthEnvironmentPresetID } from "@/models/authEnvironment"; import { githubFeedbackIssueURL } from "@/models/feedback"; -import { formatSidebarVersionLabel } from "@/models/upgradeStatus"; +import { formatSidebarVersionLabel, hasUpgradeAttention, isLocalBuildUpgradeStatus } from "@/models/upgradeStatus"; import { classNames } from "@/shared/lib/classNames"; import { readStoredAuthEnvironmentDraft, writeStoredAuthEnvironmentDraft } from "@/shared/storage/authEnvironment"; import type { ThemeMode } from "@/shared/theme/theme"; @@ -54,7 +54,13 @@ export function SettingsPage() { : sidebar.authBusy ? sidebar.t("csghubSigningIn") : sidebar.t("settingsAccountLogin"); - const version = formatSidebarVersionLabel(sidebar.upgradeStatus?.current_version || sidebar.appVersion); + const currentVersion = sidebar.upgradeStatus?.current_version || sidebar.appVersion; + const version = formatSidebarVersionLabel(currentVersion); + const showUpgradeAction = + sidebar.showUpgradeControls && + !isLocalBuildUpgradeStatus(sidebar.upgradeStatus, currentVersion) && + sidebar.upgradeStatus?.auto_upgrade_supported !== false && + hasUpgradeAttention(sidebar.upgradeStatus, sidebar.upgradePhase, sidebar.upgradeBusy); const feedbackURL = githubFeedbackIssueURL(sidebar.appVersion, sidebar.upgradeStatus); const activeAuthEnvironmentDraft = signedIn ? authEnvironmentDraftFromStatus(sidebar.authStatus, authEnvironmentDraft) @@ -259,9 +265,14 @@ export function SettingsPage() { -
+
{sidebar.t("settingsCurrentVersion")} {version} + {showUpgradeAction ? ( + + ) : null}
diff --git a/web/app/tests/components/SettingsPage.test.tsx b/web/app/tests/components/SettingsPage.test.tsx new file mode 100644 index 00000000..84a5090b --- /dev/null +++ b/web/app/tests/components/SettingsPage.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { WorkspaceControllerProvider } from "@/hooks/workspace"; +import type { WorkspaceController } from "@/hooks/workspace"; +import { emptyAuthStatus } from "@/models/auth"; +import type { TranslateFn } from "@/models/conversations"; +import { SettingsPage } from "@/pages/SettingsPage/SettingsPage"; + +const labels: Record = { + settings: "Settings", + settingsAccountLogin: "Log in", + settingsAppearanceDescription: "Appearance settings.", + settingsCommunityAccount: "Community account", + settingsCommunityAccountDescription: "Manage your community account.", + settingsCurrentVersion: "Current version", + settingsEnvironmentDescription: "Choose a site.", + settingsFeedbackDescription: "Send feedback.", + settingsPageSubtitle: "Manage product settings.", + settingsParametersDescription: "Configure parameters.", + settingsVersionDescription: "View the current version and update status.", + upgradeAction: "Update & Restart", +}; + +const t: TranslateFn = (key) => labels[key] ?? key; + +describe("SettingsPage", () => { + it("opens the upgrade flow when an update is available", () => { + const onOpenUpgrade = vi.fn(); + const controller = { + ready: true, + sidebarProps: { + appVersion: "0.0.101", + authBusy: false, + authError: "", + authPending: false, + authStatus: emptyAuthStatus(), + locale: "en", + onLocaleChange: vi.fn(), + onLogin: vi.fn(), + onLogout: vi.fn(), + onOpenConfigSettings: vi.fn(), + onOpenUpgrade, + onThemeChange: vi.fn(), + showUpgradeControls: true, + t, + theme: "light", + upgradeBusy: false, + upgradePhase: "idle", + upgradeStatus: { + auto_upgrade_supported: true, + auto_upgrade_unsupported_reason: "", + checking: false, + current_version: "0.0.101", + last_checked_at: "", + last_error: "", + last_error_kind: "", + last_error_log_path: "", + latest_version: "v0.3.18", + manual_restart_required: false, + update_available: true, + upgrading: false, + }, + }, + } as unknown as WorkspaceController; + + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Update & Restart" })); + + expect(onOpenUpgrade).toHaveBeenCalledTimes(1); + }); +});