Skip to content
Merged
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
6 changes: 6 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,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 已创建后不再打回登录页。
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 dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion dashboard/src/pages/Organization/OrgRestartOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -112,7 +114,14 @@ export default function OrgRestartOverlay({
{t("organization.previewNeedsRestartTitle")}
</h2>
<p className={styles.restartGateBody}>
{t("organization.previewNeedsRestart")}
{error ? (
<>
{t("organization.restartSidecarFailed", { detail: "" })}{" "}
{error}
</>
) : (
t("organization.previewNeedsRestart")
)}
</p>
<Button
type="primary"
Expand Down
59 changes: 56 additions & 3 deletions dashboard/src/pages/Organization/OrganizationEntry.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StrictMode } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
Expand All @@ -18,8 +19,8 @@ vi.mock("../../utils/desktopShell", () => ({
isDesktopShell: vi.fn(),
}));

function renderEntry() {
return render(
function renderEntry(strict = false) {
const tree = (
<MemoryRouter initialEntries={["/organization"]}>
<Routes>
<Route path="/organization" element={<OrganizationEntry />} />
Expand All @@ -28,8 +29,9 @@ function renderEntry() {
element={<div data-testid="org-ui-workspace">workspace</div>}
/>
</Routes>
</MemoryRouter>,
</MemoryRouter>
);
return render(strict ? <StrictMode>{tree}</StrictMode> : tree);
}

describe("OrganizationEntry", () => {
Expand Down Expand Up @@ -111,6 +113,57 @@ describe("OrganizationEntry", () => {
expect(screen.queryByTestId("org-openxyos-frame")).toBeNull();
});

it("opens the organization after its automatic start becomes healthy", async () => {
vi.mocked(isDesktopShell).mockReturnValue(true);
vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({
integrated: true,
authority: "dual",
});
vi.mocked(orgModuleApi.probeLivez)
.mockResolvedValueOnce({
reachable: false,
url: "http://127.0.0.1:3780",
detail: "sidecar unreachable",
})
.mockResolvedValueOnce({
reachable: false,
url: "http://127.0.0.1:3780",
detail: "sidecar unreachable",
})
.mockResolvedValue({
reachable: true,
url: "http://127.0.0.1:3780",
detail: "ok",
});
renderEntry(true);
expect(await screen.findByTestId("org-openxyos-frame")).toBeInTheDocument();
expect(orgModuleApi.startSidecar).toHaveBeenCalledTimes(1);
});

it("shows the startup failure instead of leaving the user at the port wait step", async () => {
vi.mocked(isDesktopShell).mockReturnValue(true);
vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({
integrated: true,
authority: "dual",
});
vi.mocked(orgModuleApi.probeLivez).mockResolvedValue({
reachable: false,
url: "http://127.0.0.1:3780",
detail: "sidecar unreachable",
});
vi.mocked(orgModuleApi.startSidecar).mockResolvedValue({
started: true,
already: false,
reachable: false,
detail: "database could not be opened",
});
renderEntry();
expect(
await screen.findByText(/database could not be opened/),
).toBeInTheDocument();
expect(screen.getByTestId("org-sidecar-gate")).toBeInTheDocument();
});

it("falls back to the host org-ui workspace when identity status fails", async () => {
vi.mocked(orgModuleApi.identityStatus).mockRejectedValue(
new Error("organization identity unavailable"),
Expand Down
48 changes: 28 additions & 20 deletions dashboard/src/pages/Organization/OrganizationEntry.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Navigate } from "react-router-dom";
import { Spin } from "antd";
import { orgModuleApi } from "../../api/modules/orgModule";
Expand Down Expand Up @@ -28,7 +28,8 @@ export default function OrganizationEntry() {
const [livezOk, setLivezOk] = useState(false);
const [probed, setProbed] = useState(false);
const [restarting, setRestarting] = useState(false);
const [autoTried, setAutoTried] = useState(false);
const autoTried = useRef(false);
const [startError, setStartError] = useState("");

useEffect(() => {
let active = true;
Expand Down Expand Up @@ -68,34 +69,40 @@ export default function OrganizationEntry() {
}, [embed]);

useEffect(() => {
if (!embed || !probed || livezOk || autoTried) return;
setAutoTried(true);
if (!embed || !probed || livezOk || autoTried.current) return;
autoTried.current = true;
setRestarting(true);
let active = true;
setStartError("");
void orgModuleApi
.startSidecar()
.then(() => orgModuleApi.probeLivez())
.then((result) => {
if (active) setLivezOk(Boolean(result.reachable));
.then(async (start) => {
const result = await orgModuleApi.probeLivez();
setLivezOk(Boolean(result.reachable));
if (!result.reachable)
setStartError(start.detail || result.detail || "");
})
.catch(() => {
if (active) setLivezOk(false);
.catch((error: unknown) => {
setLivezOk(false);
setStartError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
if (active) setRestarting(false);
});
return () => {
active = false;
};
}, [embed, probed, livezOk, autoTried]);
.finally(() => setRestarting(false));
}, [embed, probed, livezOk]);

const onRestart = () => {
setRestarting(true);
setStartError("");
void orgModuleApi
.restartSidecar()
.then(() => orgModuleApi.probeLivez())
.then((result) => setLivezOk(Boolean(result.reachable)))
.catch(() => setLivezOk(false))
.then(async (restart) => {
const result = await orgModuleApi.probeLivez();
setLivezOk(Boolean(result.reachable));
if (!result.reachable)
setStartError(restart.detail || result.detail || "");
})
.catch((error: unknown) => {
setLivezOk(false);
setStartError(error instanceof Error ? error.message : String(error));
})
.finally(() => setRestarting(false));
};

Expand All @@ -118,6 +125,7 @@ export default function OrganizationEntry() {
return (
<OrgRestartOverlay
mode={gate === "restarting" ? "restarting" : "needsRestart"}
error={startError}
onRestart={onRestart}
/>
);
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
7 changes: 4 additions & 3 deletions desktop/src/desktop_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,22 @@ func processLogPath(name string) string {
return filepath.Join(desktopLogDir(), name+".log")
}

func initDesktopLog() {
func initDesktopLog() *os.File {
if err := os.MkdirAll(desktopLogDir(), 0o755); err != nil {
log.SetOutput(os.Stderr)
log.Printf("desktop log dir: %v", err)
return
return nil
}
f, err := os.OpenFile(desktopLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
log.SetOutput(os.Stderr)
log.Printf("desktop log file: %v", err)
return
return nil
}
log.SetOutput(io.MultiWriter(f, os.Stderr))
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
log.Printf("desktop log %s", desktopLogPath())
return f
}

func attachProcessLogFile(name string) (*os.File, error) {
Expand Down
11 changes: 10 additions & 1 deletion desktop/src/desktop_log_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"log"
"os"
"path/filepath"
"runtime"
Expand All @@ -25,7 +26,15 @@ func TestInitDesktopLogWritesFile(t *testing.T) {
home := t.TempDir()
t.Setenv("FREEOS_HOME", home)
t.Setenv("OCTOP_HOME", "")
initDesktopLog()
writer, flags := log.Writer(), log.Flags()
logFile := initDesktopLog()
t.Cleanup(func() {
log.SetOutput(writer)
log.SetFlags(flags)
if logFile != nil {
logFile.Close()
}
})
data, err := os.ReadFile(desktopLogPath())
if err != nil {
t.Fatal(err)
Expand Down
Loading
Loading