Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/octop-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 密钥。
Expand All @@ -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 已创建后不再打回登录页。
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<p align="center">
<a href="https://www.python.org/downloads/"><img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" /></a>
<a href="https://github.com/XYAIStudio/FreeOS/releases"><img alt="Version" src="https://img.shields.io/badge/version-0.0.4-orange" /></a>
<a href="https://github.com/XYAIStudio/FreeOS/releases"><img alt="Version" src="https://img.shields.io/badge/version-0.0.6-orange" /></a>
<a href="LICENSE"><img alt="License: MIT + Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2B%20Apache--2.0-green" /></a>
<a href="https://github.com/TencentCloud/Octop"><img alt="Upstream: Octop" src="https://img.shields.io/badge/Upstream-Octop-1677ff.svg?style=flat" /></a>
<a href="https://github.com/XYAIStudio/openXYOS"><img alt="Organization module: openXYOS" src="https://img.shields.io/badge/Organization-openXYOS-0f766e.svg?style=flat" /></a>
Expand Down Expand Up @@ -136,7 +136,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md).
`FreeOS-desktop-windows-arm64-<version>.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)。

Expand Down
2 changes: 1 addition & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<p align="center">
<a href="https://www.python.org/downloads/"><img alt="Python 3.12+" src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" /></a>
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green" /></a>
<a href="https://github.com/XYAIStudio/FreeOS/releases"><img alt="Version" src="https://img.shields.io/badge/version-0.0.4-orange" /></a>
<a href="https://github.com/XYAIStudio/FreeOS/releases"><img alt="Version" src="https://img.shields.io/badge/version-0.0.6-orange" /></a>
<a href="https://github.com/TencentCloud/Octop"><img alt="上游:Octop" src="https://img.shields.io/badge/上游-Octop-1677ff.svg?style=flat" /></a>
<a href="https://github.com/XYAIStudio/openXYOS"><img alt="组织模块:openXYOS" src="https://img.shields.io/badge/组织模块-openXYOS-0f766e.svg?style=flat" /></a>
<a href="https://pypi.org/project/octop/"><img src="https://img.shields.io/pypi/v/octop" alt="PyPI" /></a>
Expand Down
2 changes: 1 addition & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion desktop/src/build/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/build/windows/info.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
47 changes: 45 additions & 2 deletions desktop/src/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions desktop/src/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions desktop/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 12 additions & 4 deletions desktop/src/settings_home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)

Expand Down Expand Up @@ -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)
}
}

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/octop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

from __future__ import annotations

__version__ = "0.0.4"
__version__ = "0.0.6"
8 changes: 8 additions & 0 deletions src/octop/infra/setup/self_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 8 additions & 6 deletions tests/unit/cli/test_version_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,21 @@
_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:
runner = CliRunner()
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:
Expand All @@ -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
30 changes: 30 additions & 0 deletions tests/unit/infra/setup/test_self_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading