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 @@
-
+
@@ -136,7 +136,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md).
`FreeOS-desktop-windows-arm64-
-
+
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" },