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..c88b2b72 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,10 @@
### 修复
+- Windows 最小化到托盘后,从托盘菜单「显示 FreeOS」或再次启动应用可稳定恢复主窗口;宿主会消费隐藏窗口产生的延迟关闭事件,避免窗口显示后立刻再次隐藏。
+- 组织页自动启动不再因 React effect 清理而取消;桌面宿主与 Python 组织启动器不再争抢同一 openXYOS 进程。组织数据库改为原子落盘,模块开关可即时更新导航并在重启后保持。
+- Ollama 模型明确返回“不支持 tools”时,同一轮请求会自动去掉工具定义后重试。`qwen2.5vl:3b` 等视觉/对话模型不再因工具能力缺失连续触发 `stream_error`。
+- 撤回错误标记为 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-.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 @@
-
+
diff --git a/dashboard/package.json b/dashboard/package.json
index 7aded21b..4d62cdec 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host",
- "build": "tsc -b && NODE_ENV=production vite build",
+ "build": "tsc -b && vite build --mode production",
"build:docker": "vite build",
"build:prod": "tsc -b && vite build --mode production",
"build:test": "tsc -b && vite build --mode test",
diff --git a/dashboard/src/pages/Organization/OrgRestartOverlay.tsx b/dashboard/src/pages/Organization/OrgRestartOverlay.tsx
index 58a57b80..0d9cb2db 100644
--- a/dashboard/src/pages/Organization/OrgRestartOverlay.tsx
+++ b/dashboard/src/pages/Organization/OrgRestartOverlay.tsx
@@ -14,12 +14,14 @@ import styles from "./Organization.module.less";
type OrgRestartOverlayProps = {
mode: "restarting" | "needsRestart";
finished?: boolean;
+ error?: string;
onRestart: () => void;
};
export default function OrgRestartOverlay({
mode,
finished = false,
+ error = "",
onRestart,
}: OrgRestartOverlayProps) {
const { t } = useTranslation();
@@ -112,7 +114,14 @@ export default function OrgRestartOverlay({
{t("organization.previewNeedsRestartTitle")}
- {t("organization.previewNeedsRestart")}
+ {error ? (
+ <>
+ {t("organization.restartSidecarFailed", { detail: "" })}{" "}
+ {error}
+ >
+ ) : (
+ t("organization.previewNeedsRestart")
+ )}
)}
diff --git a/modules/openxyos/frontend/src/pages/SettingsPage.tsx b/modules/openxyos/frontend/src/pages/SettingsPage.tsx
index 4b980d00..fe1d9267 100644
--- a/modules/openxyos/frontend/src/pages/SettingsPage.tsx
+++ b/modules/openxyos/frontend/src/pages/SettingsPage.tsx
@@ -5,7 +5,8 @@ import { authFetch } from "../api/authFetch";
import { EXPERIENCE_MODELS } from "../llm-providers";
import { useAuthStore } from "../stores/auth";
import Avatar from "../components/Avatar";
-import ModuleManagementTab from "../components/OpenModuleManagementTab";
+import OpenModuleManagementTab from "../components/OpenModuleManagementTab";
+import ModuleManagementTab from "../components/ModuleManagementTab";
import { useLocale } from "../i18n";
interface CompanySettings { name: string; settings: Record; }
@@ -241,7 +242,9 @@ export default function SettingsPage() {
)}
- {tab === "modules" && isAdmin && user && }
+ {tab === "modules" && isAdmin && user && (import.meta.env.VITE_FREEOS_ORG_INTEGRATED === "true"
+ ?
+ : )}
{tab === "ai" && }
diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json
index 79cba93e..9a9bf822 100644
--- a/modules/openxyos/package.json
+++ b/modules/openxyos/package.json
@@ -11,7 +11,8 @@
"postinstall": "node scripts/patch-express-types.cjs",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx --max-warnings 0",
- "test": "npm run test:runtime-config && npm run test:cors-origins && npm run test:embed-trap && npm run test:hero-mascot && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:freeos-ingest && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100",
+ "test": "npm run test:db-persistence && npm run test:runtime-config && npm run test:cors-origins && npm run test:embed-trap && npm run test:hero-mascot && npm run test:r0-access-control && npm run test:module-settings && npm run test:llm-onboarding && npm run test:agent-studio && npm run test:freeos-ingest && npm run test:file-security && npm run test:download-token && npm run test:ws-ticket && npm run test:auth-provider && npm run test:pep && npm run test:audit-bundle && npm run test:tool-gateway && npm run test:v100",
+ "test:db-persistence": "tsx scripts/test-db-persistence.ts",
"test:runtime-config": "tsx scripts/test-runtime-config.ts",
"test:cors-origins": "tsx scripts/test-cors-origins.ts",
"test:embed-trap": "tsx scripts/test-embed-trap.ts",
diff --git a/modules/openxyos/scripts/test-db-persistence.ts b/modules/openxyos/scripts/test-db-persistence.ts
new file mode 100644
index 00000000..250c0c7d
--- /dev/null
+++ b/modules/openxyos/scripts/test-db-persistence.ts
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+async function main() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openxyos-db-"));
+ const dbPath = path.join(dir, "xiongyuan.db");
+ try {
+ fs.writeFileSync(dbPath, Buffer.alloc(4096));
+ process.env.DATABASE_PATH = dbPath;
+ const { initDatabase, saveDb, dbRun } = await import("../backend/db");
+ await initDatabase();
+ assert.equal(fs.readdirSync(dir).filter((name) => name.endsWith(".bak")).length, 1);
+ dbRun("CREATE TABLE IF NOT EXISTS persistence_test (value TEXT)");
+ dbRun("INSERT INTO persistence_test (value) VALUES (?)", ["saved"]);
+ saveDb();
+ assert.equal(fs.readFileSync(dbPath).subarray(0, 16).toString(), "SQLite format 3\0");
+ assert.equal(fs.readdirSync(dir).filter((name) => name.endsWith(".tmp")).length, 0);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/modules/openxyos/scripts/test-module-settings.ts b/modules/openxyos/scripts/test-module-settings.ts
index 306e1a18..a1c22a8c 100644
--- a/modules/openxyos/scripts/test-module-settings.ts
+++ b/modules/openxyos/scripts/test-module-settings.ts
@@ -14,6 +14,7 @@ async function main() {
const { initDatabase, dbRun } = await import("../backend/db");
const { openModuleSettingsRoutes: moduleSettingsRoutes } = await import("../backend/routes/open-module-settings");
+ const { moduleSettingsRoutes: integratedModuleSettingsRoutes } = await import("../backend/routes/module-settings");
const { signToken } = await import("../backend/middleware");
await initDatabase();
@@ -32,6 +33,7 @@ async function main() {
const app = express();
app.use(express.json());
app.use("/api/module-settings", moduleSettingsRoutes);
+ app.use("/api/integrated-module-settings", integratedModuleSettingsRoutes);
const server = app.listen(0, "127.0.0.1");
let baseUrl = "";
@@ -61,6 +63,16 @@ async function main() {
assert.equal(savedBody.data.modules.find((module: any) => module.key === "employees").label, "协同成员");
assert.equal(savedBody.data.modules.length, 12);
+ const integratedSaved = await request(tokens.admin, "/api/integrated-module-settings", {
+ method: "PUT",
+ body: JSON.stringify({ updates: { chat: false } }),
+ });
+ assert.equal(integratedSaved.status, 200);
+ const integratedRead = await request(tokens.user, "/api/integrated-module-settings");
+ assert.equal(integratedRead.status, 200);
+ const integratedBody = await integratedRead.json() as any;
+ assert.equal(integratedBody.data.modules.find((module: any) => module.key === "chat").enabled, false);
+
const userWrite = await request(tokens.user, "/api/module-settings", {
method: "PUT",
body: JSON.stringify({ updates: { tasks: true } }),
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/agents/manager.py b/src/octop/infra/agents/manager.py
index 8e3cee32..c9d42eb9 100644
--- a/src/octop/infra/agents/manager.py
+++ b/src/octop/infra/agents/manager.py
@@ -2782,6 +2782,9 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig:
from octop.infra.agents.middleware.binary_read_guard import BinaryReadGuardMiddleware
from octop.infra.agents.middleware.browser_profile import BrowserProfileMiddleware
from octop.infra.agents.middleware.group_mentions import GroupMentionMiddleware
+ from octop.infra.agents.middleware.ollama_tool_fallback import (
+ OllamaToolFallbackMiddleware,
+ )
from octop.infra.agents.middleware.org_governance import OrgGovernanceMiddleware
from octop.infra.agents.middleware.reasoning import ReasoningRequestMiddleware
from octop.infra.agents.middleware.thread_artifacts import ThreadArtifactsMiddleware
@@ -2803,6 +2806,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig:
usage_repo=self._repos.usage_repo,
),
ReasoningRequestMiddleware(),
+ OllamaToolFallbackMiddleware(),
GroupMentionMiddleware(lambda: self._harness_manager),
KnowledgeSearchHintMiddleware(),
BrowserProfileMiddleware(),
diff --git a/src/octop/infra/agents/middleware/ollama_tool_fallback.py b/src/octop/infra/agents/middleware/ollama_tool_fallback.py
new file mode 100644
index 00000000..10fe61c1
--- /dev/null
+++ b/src/octop/infra/agents/middleware/ollama_tool_fallback.py
@@ -0,0 +1,37 @@
+"""Retry Ollama models without tools when the model rejects tool calling."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
+
+
+def _is_unsupported_tools_error(exc: Exception) -> bool:
+ message = str(exc).lower()
+ return "does not support tools" in message or "doesn't support tools" in message
+
+
+class OllamaToolFallbackMiddleware(AgentMiddleware[Any, Any]):
+ """Preserve chat for local models that do not implement tool calling.
+
+ Ollama's OpenAI-compatible endpoint rejects such requests with HTTP 400.
+ Retrying the same turn without tools is safe and lets vision/chat-only
+ models answer normally instead of exhausting the generic model retry loop.
+ """
+
+ async def awrap_model_call(
+ self,
+ request: ModelRequest[Any],
+ handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
+ ) -> ModelResponse[Any]:
+ try:
+ return await handler(request)
+ except Exception as exc:
+ if not request.tools or not _is_unsupported_tools_error(exc):
+ raise
+ return await handler(request.override(tools=[], tool_choice=None))
+
+
+__all__ = ["OllamaToolFallbackMiddleware"]
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/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py
index 2452b996..4556b33a 100644
--- a/src/octop/modules/org_os/sidecar_launch.py
+++ b/src/octop/modules/org_os/sidecar_launch.py
@@ -551,6 +551,23 @@ def start_sidecar(
detail="sidecar already reachable",
launcher=launcher_label,
)
+ if os.environ.get("FREEOS_DESKTOP") == "1" and os.environ.get("OPENXYOS_BASE_URL"):
+ # The desktop host already supervises this Node process. Starting a
+ # second sidecar here would stop/race the managed process and DB writer.
+ latest = _wait_reachable(service, min(wait, 5.0))
+ return SidecarStartResult(
+ started=False,
+ already=latest.reachable,
+ reachable=latest.reachable,
+ url=latest.url,
+ command=command,
+ detail=(
+ "managed runtime reachable"
+ if latest.reachable
+ else "managed runtime is restarting; check logs/organization-runtime.log"
+ ),
+ launcher=launcher_label,
+ )
if not sidecar_can_start():
return SidecarStartResult(
started=False,
@@ -629,7 +646,9 @@ def _wait_until_down(service: OrgModuleService, wait: float = 8.0) -> None:
def restart_sidecar(service: OrgModuleService, *, wait: float = 45.0) -> SidecarStartResult:
- """Stop the live openXYOS Node, then start FE+BE again and wait for livez."""
+ """Restart standalone sidecar; defer to the desktop supervisor when managed."""
+ if os.environ.get("FREEOS_DESKTOP") == "1" and os.environ.get("OPENXYOS_BASE_URL"):
+ return start_sidecar(service, wait=wait)
root = sidecar_runtime_root()
if root is not None:
heal_openxyos_layout(root)
diff --git a/tests/unit/agents/test_ollama_tool_fallback.py b/tests/unit/agents/test_ollama_tool_fallback.py
new file mode 100644
index 00000000..b39885b1
--- /dev/null
+++ b/tests/unit/agents/test_ollama_tool_fallback.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import Mock
+
+import pytest
+from langchain.agents.middleware import ModelRequest, ModelResponse
+
+from octop.infra.agents.middleware.ollama_tool_fallback import (
+ OllamaToolFallbackMiddleware,
+)
+
+
+def _request(*, tools: list[Any]) -> ModelRequest[Any]:
+ return ModelRequest(model=Mock(), messages=[], tools=tools)
+
+
+@pytest.mark.asyncio
+async def test_retries_without_tools_when_ollama_rejects_them() -> None:
+ middleware = OllamaToolFallbackMiddleware()
+ seen: list[ModelRequest[Any]] = []
+ expected = Mock(spec=ModelResponse)
+
+ async def handler(request: ModelRequest[Any]) -> ModelResponse[Any]:
+ seen.append(request)
+ if len(seen) == 1:
+ raise RuntimeError("qwen2.5vl:3b does not support tools")
+ return expected
+
+ result = await middleware.awrap_model_call(_request(tools=[Mock()]), handler)
+
+ assert result is expected
+ assert len(seen) == 2
+ assert seen[1].tools == []
+ assert seen[1].tool_choice is None
+
+
+@pytest.mark.asyncio
+async def test_does_not_hide_unrelated_model_errors() -> None:
+ middleware = OllamaToolFallbackMiddleware()
+
+ async def handler(_request: ModelRequest[Any]) -> ModelResponse[Any]:
+ raise RuntimeError("authentication failed")
+
+ with pytest.raises(RuntimeError, match="authentication failed"):
+ await middleware.awrap_model_call(_request(tools=[Mock()]), handler)
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/desktop/test_capture_display.py b/tests/unit/desktop/test_capture_display.py
index 8ccfdab5..abf1a73b 100644
--- a/tests/unit/desktop/test_capture_display.py
+++ b/tests/unit/desktop/test_capture_display.py
@@ -5,7 +5,8 @@
from octop.infra.desktop.capture import display_str, is_linux_virtual_display
-def test_display_str_defaults() -> None:
+def test_display_str_defaults(monkeypatch) -> None:
+ monkeypatch.delenv("DISPLAY", raising=False)
assert display_str(None).startswith(":")
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/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py
index b8d7b72e..6a170536 100644
--- a/tests/unit/test_sidecar_launch.py
+++ b/tests/unit/test_sidecar_launch.py
@@ -96,6 +96,33 @@ def test_start_sidecar_already_reachable(tmp_path: Path, monkeypatch: pytest.Mon
assert result.started is False
+def test_desktop_start_does_not_spawn_second_managed_node(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from octop.modules.org_os import sidecar_launch
+ from octop.modules.org_os.service import SidecarHealth
+
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ monkeypatch.setenv("FREEOS_DESKTOP", "1")
+ monkeypatch.setenv("OPENXYOS_BASE_URL", "http://127.0.0.1:3780")
+ monkeypatch.setattr(
+ service,
+ "probe_sidecar",
+ lambda timeout=2.0: SidecarHealth(reachable=False, url="http://127.0.0.1:3780"),
+ )
+ monkeypatch.setattr(
+ sidecar_launch, "_wait_reachable", lambda service, wait: service.probe_sidecar()
+ )
+ monkeypatch.setattr(sidecar_launch, "_spawn", lambda service: pytest.fail("duplicate spawn"))
+ monkeypatch.setattr(
+ sidecar_launch, "stop_stale_openxyos", lambda root: pytest.fail("managed Node stopped")
+ )
+
+ result = sidecar_launch.restart_sidecar(service, wait=0.1)
+ assert result.reachable is False
+ assert "managed runtime is restarting" in result.detail
+
+
def test_start_sidecar_restarts_when_embed_blocked(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
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" },