diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index b9688796..65d5c623 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -153,6 +153,15 @@ jobs: run: | set -euo pipefail VER=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' pyproject.toml | head -1) + requested_tag="${{ github.event.inputs.release_tag }}" + if [[ "${GITHUB_REF}" == refs/tags/v* && "${GITHUB_REF_NAME#v}" != "$VER" ]]; then + echo "Refusing desktop release: tag ${GITHUB_REF_NAME} does not match pyproject.toml $VER" >&2 + exit 1 + fi + if [[ -n "$requested_tag" && "${requested_tag#v}" != "$VER" ]]; then + echo "Refusing desktop release: requested tag $requested_tag does not match pyproject.toml $VER" >&2 + exit 1 + fi echo "OCTOP_VERSION=${VER}" >> "$GITHUB_ENV" echo "version=${VER}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 563a6c19..673f37ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,16 @@ jobs: with: fetch-depth: 0 + - name: Verify release tag matches package version + run: | + set -euo pipefail + package_version=$(sed -nE 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' pyproject.toml | head -1) + tag_version="${GITHUB_REF_NAME#v}" + if [[ -z "$package_version" || "$package_version" != "$tag_version" ]]; then + echo "Refusing release: tag $GITHUB_REF_NAME does not match pyproject.toml $package_version" >&2 + exit 1 + fi + - uses: astral-sh/setup-uv@v4 with: enable-cache: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0224afc9..88c18013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ## [Unreleased] +## [0.0.6] - 2026-09-22 + ### 安全 - 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写(即使环境里有 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_API_KEY` 也不写入 `providers`)。打包排除 `.env`、`octop.db`、`.freeos`,打 zip 前扫描 staging。生产/air-gap sidecar 不再回退 `LLM_API_KEY`。已发布的 **0.0.1–0.0.4** 安装包须下架(已从 Release 删除),并轮换可能泄露的 DeepSeek 密钥。 @@ -18,6 +20,7 @@ ### 修复 +- 撤回错误标记为 0.0.5、内部实际仍为 0.0.4 的 Windows 构建。桌面更新现在拒绝版本元数据与 portable 包内部版本不一致的下载,也拒绝重复安装当前或更旧版本,防止同一包循环下载、启动时反复解压约 1.15 GB / 6.8 万文件并拖垮系统。Windows WebView2 默认使用软件渲染,降低显卡驱动黑屏风险;发布工作流在标签与 `pyproject.toml` 版本不一致时直接失败。 - 组织嵌入仍走 openXYOS 自己的登录(`localStorage token` / sidecar JWT),不把 FreeOS 桌面访客(`auth_token` / `POST /api/auth/local-session`)写进组织房间。重启/恢复 sidecar 始终带 `FREEOS_ORG_LOCAL_TEST=1`,保留 `demo@demo.com` / `user@demo.com`。本机会话跳过组织映射行,不删除、不占用 openXYOS 用户。代理剥离宿主 Cookie 与 `X-FreeOS-*`。 / Org embed keeps openXYOS login; FreeOS local-session must not replace org users. Sidecar restart still seeds test accounts. Proxy drops host cookies and identity headers. - Windows 升级刷新 `~/.freeos/portable` 时,若目录节点被占用(无法改名为 `portable.previous`,报 “being used by another process”),先尝试结束残留的 portable / `launch.py` 进程,再把新运行时**原地覆盖**进现有文件夹,避免留下空的锁定 `portable` 桩,也不要求重启。 / If renaming `portable` → `portable.previous` fails because the directory is in use, stop leftover host processes and overlay the new runtime in place. - 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳。403 修复后的路径是:可选模型配置(云 Key / 本机,可跳过)→ 第一个智能体 `/chat/main`,不经过登录墙,也不停在工作台列表。`/setup` 在 guest 已创建后不再打回登录页。 diff --git a/README.md b/README.md index fa609aa5..55df7923 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Python 3.12+ - Version + Version License: MIT + Apache-2.0 Upstream: Octop Organization module: openXYOS @@ -136,7 +136,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md). `FreeOS-desktop-windows-arm64-.exe`(ARM 电脑)。 2. 双击安装包。安装程序会放到「程序文件」并创建开始菜单和桌面快捷方式。 3. 打开 **FreeOS**。第一次启动会解压内置运行环境(可能要一两分钟),然后直接进入可用会话,无需先登录。 -4. 保存、导出或发布到账号时再在本机完成注册或登录(更广的服务或授权不挡起步)。侧栏 **Organization** 当前默认走宿主内组织能力;桌面 0.0.4 托管 Node + iframe(延续 0.0.3 过渡桥)、以及可选的完整 openXYOS Node 栈(`127.0.0.1:3780`,导出/同步/高级部署)都是 **过渡桥**,终态是把 openXYOS 迁入宿主。组织能力像工作室里另一间可独立布置的房间,与日常对话、助手协作同在一个 FreeOS,两种进入方式不捏成一种。 +4. 保存、导出或发布到账号时再在本机完成注册或登录(更广的服务或授权不挡起步)。侧栏 **Organization** 当前默认走宿主内组织能力;桌面 0.0.6 托管 Node + iframe(延续 0.0.3 过渡桥)、以及可选的完整 openXYOS Node 栈(`127.0.0.1:3780`,导出/同步/高级部署)都是 **过渡桥**,终态是把 openXYOS 迁入宿主。组织能力像工作室里另一间可独立布置的房间,与日常对话、助手协作同在一个 FreeOS,两种进入方式不捏成一种。 数据目录默认是 `%USERPROFILE%\.freeos`(可用环境变量 `FREEOS_HOME` 改)。旧版 Octop 的 `~/.octop` 仍会被识别。卸载安装包会清空安装目录(默认为 `Program Files\FreeOS`)并删除快捷方式,但**不会**删除该用户数据目录;详见 [desktop/README.md](desktop/README.md#windows-uninstall)。 diff --git a/README_CN.md b/README_CN.md index ae0ca174..fd7b3dcc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,7 +11,7 @@

Python 3.12+ License: MIT - Version + Version 上游:Octop 组织模块:openXYOS PyPI diff --git a/desktop/README.md b/desktop/README.md index 9e5726aa..9513b7c1 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -147,7 +147,7 @@ opt into `SHIP_OPENXYOS_RUNTIME=1`): make -f desktop/portable/Makefile green ``` -The managed Node sidecar is included in the 0.0.4 desktop release so the +The managed Node sidecar is included in the 0.0.6 desktop release so the integrated Organization workspace works in an offline installation. Set `SKIP_ORG_SIDECAR=1` only for an explicitly slim, host-only development build. diff --git a/desktop/src/build/config.yml b/desktop/src/build/config.yml index d8d2cc63..5cb28f52 100644 --- a/desktop/src/build/config.yml +++ b/desktop/src/build/config.yml @@ -11,7 +11,7 @@ info: description: "FreeOS desktop — Python host with in-host organization" copyright: "(c) 2026, XYAI Studio" comments: "Wails v3 + green portable. Node sidecar optional via FREEOS_ORG_SIDECAR=1." - version: "0.0.4" + version: "0.0.6" dev_mode: root_path: . diff --git a/desktop/src/build/windows/info.json b/desktop/src/build/windows/info.json index 9446f255..cde3e291 100644 --- a/desktop/src/build/windows/info.json +++ b/desktop/src/build/windows/info.json @@ -1,10 +1,10 @@ { "fixed": { - "file_version": "0.0.4" + "file_version": "0.0.6" }, "info": { "0000": { - "ProductVersion": "0.0.4", + "ProductVersion": "0.0.6", "CompanyName": "XYAI Studio", "FileDescription": "FreeOS desktop", "LegalCopyright": "Copyright © 2026 XYAI Studio", diff --git a/desktop/src/download.go b/desktop/src/download.go index 83a68869..a19732b2 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -569,17 +569,60 @@ func pendingPortableZip() string { if err != nil { return "" } - defer reader.Close() - if stampFromZip(reader.File) == "" { + stamp := stampFromZip(reader.File) + archiveVersion, versionErr := versionFromZip(reader.File) + _ = reader.Close() + if stamp == "" || versionErr != nil || archiveVersion == "" { + rejectPendingPortable(path, "archive is missing a valid FreeOS stamp or version") + return "" + } + metaPath := filepath.Join(productHome(), "updates", "pending.json") + if data, readErr := os.ReadFile(metaPath); readErr == nil { + var meta struct { + Version string `json:"version"` + } + if json.Unmarshal(data, &meta) != nil || strings.TrimSpace(meta.Version) == "" { + rejectPendingPortable(path, "pending metadata is invalid") + return "" + } + if compareVersions(meta.Version, archiveVersion) != 0 { + rejectPendingPortable(path, fmt.Sprintf( + "release version %s does not match archive version %s", + meta.Version, + archiveVersion, + )) + return "" + } + } + currentVersion := portableVersion(portableDir()) + if currentVersion != "" && compareVersions(archiveVersion, currentVersion) <= 0 { + rejectPendingPortable(path, fmt.Sprintf( + "archive version %s is not newer than installed version %s", + archiveVersion, + currentVersion, + )) return "" } return path } +func rejectPendingPortable(path, reason string) { + dir := filepath.Dir(path) + log.Printf("portable: rejecting pending update: %s", reason) + _ = os.Remove(path) + _ = os.Remove(filepath.Join(dir, "pending.json")) + _ = os.WriteFile( + filepath.Join(dir, "pending-rejected.txt"), + []byte(time.Now().Format(time.RFC3339)+" "+reason+"\n"), + 0o644, + ) +} + func clearPendingPortable() { dir := filepath.Join(productHome(), "updates") _ = os.Remove(filepath.Join(dir, "pending-portable.zip")) _ = os.Remove(filepath.Join(dir, "pending.json")) + _ = os.Remove(filepath.Join(dir, "pending-rejected.txt")) } func extractPortable(root string) error { diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index cbc55b64..e4e2d538 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -251,6 +251,47 @@ func TestEnsurePortableAppliesPendingFreeOSZip(t *testing.T) { } } +func TestEnsurePortableRejectsMislabeledPendingZip(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + t.Setenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE", "") + root := portableDir() + + currentZip := filepath.Join(t.TempDir(), "current.zip") + writeTestGreenZipWithStamp(t, currentZip, "0.0.4", "current-build") + if err := unzipGreen(currentZip, root); err != nil { + t.Fatal(err) + } + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", currentZip) + sentinel := filepath.Join(root, "keep.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + + pendingDir := filepath.Join(home, "updates") + if err := os.MkdirAll(pendingDir, 0o755); err != nil { + t.Fatal(err) + } + pending := filepath.Join(pendingDir, "pending-portable.zip") + writeTestGreenZipWithStamp(t, pending, "0.0.4", "mislabeled-build") + if err := os.WriteFile(filepath.Join(pendingDir, "pending.json"), []byte(`{"version":"0.0.5"}`), 0o644); err != nil { + t.Fatal(err) + } + + if err := ensurePortable(LocaleZH, func(string) {}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("rejected update must preserve the current runtime: %v", err) + } + if _, err := os.Stat(pending); !os.IsNotExist(err) { + t.Fatalf("rejected update zip must be removed: %v", err) + } + if _, err := os.Stat(filepath.Join(pendingDir, "pending-rejected.txt")); err != nil { + t.Fatalf("rejection reason must be recorded: %v", err) + } +} + func TestEnsurePortableReplacesOctopLineageWithFreeOS(t *testing.T) { home := t.TempDir() t.Setenv("OCTOP_HOME", home) diff --git a/desktop/src/main.go b/desktop/src/main.go index 81791a59..d641fdec 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -24,12 +24,17 @@ var assets embed.FS const trayDoubleClick = 400 * time.Millisecond func webviewAcceptanceArgs() []string { + args := []string{"--disable-gpu", "--disable-gpu-compositing"} + if strings.EqualFold(strings.TrimSpace(os.Getenv("FREEOS_WEBVIEW_GPU")), "1") || + strings.EqualFold(strings.TrimSpace(os.Getenv("FREEOS_WEBVIEW_GPU")), "true") { + args = nil + } raw := strings.TrimSpace(os.Getenv("FREEOS_WEBVIEW_DEBUG_PORT")) port, err := strconv.Atoi(raw) if err != nil || port < 1024 || port > 65535 { - return nil + return args } - return []string{fmt.Sprintf("--remote-debugging-port=%d", port)} + return append(args, fmt.Sprintf("--remote-debugging-port=%d", port)) } // App is the Wails service bound to the shell UI. diff --git a/desktop/src/settings_home_test.go b/desktop/src/settings_home_test.go index e1c27646..80340860 100644 --- a/desktop/src/settings_home_test.go +++ b/desktop/src/settings_home_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "reflect" "testing" ) @@ -112,17 +113,24 @@ func TestOrgRuntimeProvisionDefaultsOnAndCanBeDisabled(t *testing.T) { func TestWebviewAcceptanceArgsRequiresValidExplicitPort(t *testing.T) { t.Setenv("FREEOS_WEBVIEW_DEBUG_PORT", "") - if got := webviewAcceptanceArgs(); got != nil { - t.Fatalf("debugging must be off by default: %v", got) + t.Setenv("FREEOS_WEBVIEW_GPU", "") + wantSafe := []string{"--disable-gpu", "--disable-gpu-compositing"} + if got := webviewAcceptanceArgs(); !reflect.DeepEqual(got, wantSafe) { + t.Fatalf("safe default args = %v, want %v", got, wantSafe) } t.Setenv("FREEOS_WEBVIEW_DEBUG_PORT", "9223") got := webviewAcceptanceArgs() - if len(got) != 1 || got[0] != "--remote-debugging-port=9223" { + wantDebug := append(wantSafe, "--remote-debugging-port=9223") + if !reflect.DeepEqual(got, wantDebug) { t.Fatalf("unexpected args: %v", got) } t.Setenv("FREEOS_WEBVIEW_DEBUG_PORT", "80") + if got := webviewAcceptanceArgs(); !reflect.DeepEqual(got, wantSafe) { + t.Fatalf("privileged or invalid port must be rejected without losing safe defaults: %v", got) + } + t.Setenv("FREEOS_WEBVIEW_GPU", "1") if got := webviewAcceptanceArgs(); got != nil { - t.Fatalf("privileged or invalid port must be rejected: %v", got) + t.Fatalf("explicit GPU opt-in should remove software-rendering args: %v", got) } } diff --git a/pyproject.toml b/pyproject.toml index 1ed66d90..2b226f42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.0.4" +version = "0.0.6" description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" readme = "README.md" license = { text = "MIT" } diff --git a/src/octop/__init__.py b/src/octop/__init__.py index cf42c5dd..51428754 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.4" +__version__ = "0.0.6" diff --git a/src/octop/infra/setup/self_update.py b/src/octop/infra/setup/self_update.py index 9e69d2c7..695b82ba 100644 --- a/src/octop/infra/setup/self_update.py +++ b/src/octop/infra/setup/self_update.py @@ -568,6 +568,14 @@ def run_upgrade( success=False, error=f"{target} is a pre-release; pass --allow-prerelease to install it", ) + + local = get_local_version() + if local != "0.0.0" and not is_newer(target, local): + return UpgradeResult( + success=False, + error=f"FreeOS {target} is not newer than the installed {local}", + installed_version=local, + ) plat = desktop_plat() asset = pick_portable_asset(info.assets, plat=plat, version=target) if asset is None: diff --git a/tests/unit/cli/test_version_cmd.py b/tests/unit/cli/test_version_cmd.py index 0299f0e8..1885d5d7 100644 --- a/tests/unit/cli/test_version_cmd.py +++ b/tests/unit/cli/test_version_cmd.py @@ -13,11 +13,13 @@ _REPO_ROOT = Path(__file__).resolve().parents[3] -def test_product_version_is_002() -> None: +def _declared_version() -> str: with (_REPO_ROOT / "pyproject.toml").open("rb") as fh: - declared = tomllib.load(fh)["project"]["version"] - assert declared == "0.0.4" - assert __version__ == "0.0.4" + return str(tomllib.load(fh)["project"]["version"]) + + +def test_product_version_matches_package() -> None: + assert __version__ == _declared_version() def test_version_prints_orca_version() -> None: @@ -25,7 +27,7 @@ def test_version_prints_orca_version() -> None: result = runner.invoke(cli, ["version"]) assert result.exit_code == 0 assert "octop" in result.output.lower() - assert "0.0.4" in result.output + assert _declared_version() in result.output def test_version_in_help() -> None: @@ -39,4 +41,4 @@ def test_root_version_flag() -> None: result = runner.invoke(cli, ["-v"]) assert result.exit_code == 0 assert "octop" in result.output.lower() - assert "0.0.4" in result.output + assert _declared_version() in result.output diff --git a/tests/unit/infra/setup/test_self_update.py b/tests/unit/infra/setup/test_self_update.py index 79b0cf58..c9cf87e3 100644 --- a/tests/unit/infra/setup/test_self_update.py +++ b/tests/unit/infra/setup/test_self_update.py @@ -82,6 +82,7 @@ def test_run_upgrade_stages_freeos_portable( ) monkeypatch.setattr("octop.infra.setup.self_update.fetch_release_info", lambda: info) monkeypatch.setattr("octop.infra.setup.self_update.desktop_plat", lambda: "linux-amd64") + monkeypatch.setattr("octop.infra.setup.self_update.get_local_version", lambda: "0.0.1") monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) monkeypatch.setenv("OCTOP_HOME", str(tmp_path)) monkeypatch.setenv("OCTOP_DESKTOP", "1") @@ -104,6 +105,7 @@ def test_run_upgrade_does_not_pip_install_octop( info = GitHubReleaseInfo(version="0.0.1", latest_stable="0.0.1", assets=[]) monkeypatch.setattr("octop.infra.setup.self_update.fetch_release_info", lambda: info) monkeypatch.setattr("octop.infra.setup.self_update.desktop_plat", lambda: "linux-amd64") + monkeypatch.setattr("octop.infra.setup.self_update.get_local_version", lambda: "0.0.0") monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) monkeypatch.delenv("OCTOP_DESKTOP", raising=False) monkeypatch.delenv("FREEOS_DESKTOP", raising=False) @@ -114,3 +116,31 @@ def test_run_upgrade_does_not_pip_install_octop( assert result.error is not None assert "PyPI" in result.error or "GitHub" in result.error assert "octop==" not in result.error + + +def test_run_upgrade_rejects_same_version_before_download( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + info = GitHubReleaseInfo( + version="0.0.6", + latest_stable="0.0.6", + assets=[ + ReleaseAsset( + name="FreeOS-portable-windows-amd64-0.0.6.zip", + url=( + "https://github.com/XYAIStudio/FreeOS/releases/download/" + "v0.0.6/FreeOS-portable-windows-amd64-0.0.6.zip" + ), + ) + ], + ) + monkeypatch.setattr("octop.infra.setup.self_update.fetch_release_info", lambda: info) + monkeypatch.setattr("octop.infra.setup.self_update.get_local_version", lambda: "0.0.6") + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) + + result = run_upgrade(version="0.0.6") + + assert result.success is False + assert result.installed_version == "0.0.6" + assert "not newer" in (result.error or "") + assert not pending_portable_zip(tmp_path).exists() diff --git a/uv.lock b/uv.lock index 6f1cd805..0e814928 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "octop" -version = "0.0.3" +version = "0.0.6" source = { editable = "." } dependencies = [ { name = "acme" },