From 0a4710871475bd09ae255a2822ff198e556f1d4e Mon Sep 17 00:00:00 2001 From: theater <1347507191@qq.com> Date: Wed, 23 Sep 2026 15:40:44 +0800 Subject: [PATCH 01/39] fix(update): checkpoint SQLite WAL before desktop process restart (#918) * fix(update): checkpoint SQLite WAL before desktop process restart In desktop/portable mode, POST /api/update/restart replaced the process image with os.execv without flushing the SQLite WAL. Because SqlitePool uses WAL mode with a large autocheckpoint window, all writes since the last checkpoint were lost on restart (#800). Change _restart_desktop_process to accept the OctopServer, and before execv run PRAGMA wal_checkpoint(TRUNCATE) and close the pool when the database is SQLite. The restart endpoint now injects the server via Depends(get_server). Fixes #800. * style(tests): format desktop restart regression cases --- src/octop/api/routers/update.py | 17 +++- tests/unit/api/test_update_router.py | 116 +++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/octop/api/routers/update.py b/src/octop/api/routers/update.py index 20761ab7..c1899d8f 100644 --- a/src/octop/api/routers/update.py +++ b/src/octop/api/routers/update.py @@ -22,6 +22,7 @@ update_task, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.server import OctopServer from octop.infra.setup.github_releases import SOURCE_LABEL from octop.infra.setup.self_update import ( UpgradeResult, @@ -368,8 +369,19 @@ def _is_desktop_process() -> bool: return raw in {"1", "true", "yes", "on"} -def _restart_desktop_process() -> None: +def _restart_desktop_process(server: OctopServer) -> None: time.sleep(0.4) + + # Flush SQLite WAL before replacing the process image, otherwise uncheckpointed + # writes (common in WAL mode) are lost when the connection FD is closed by execv. + if server.services is not None and getattr(server.services.db, "dialect", None) == "sqlite": + try: + with server.services.db.connect() as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + server.services.db.close() + except Exception: + logger.exception("failed to checkpoint/close database before desktop restart") + argv = list(getattr(sys, "orig_argv", None) or [sys.executable, *sys.argv]) os.execv(argv[0], argv) @@ -384,10 +396,11 @@ def _restart_service_task(runtime: ServiceRuntime) -> None: @router.post("/restart") async def restart_service_endpoint( background_tasks: BackgroundTasks, + server: OctopServer = Depends(get_server), _: Any = Depends(require_permission("update")), ) -> dict[str, Any]: if _is_desktop_process(): - background_tasks.add_task(_restart_desktop_process) + background_tasks.add_task(_restart_desktop_process, server) return {"status": "restarting", "service_mode": "desktop"} mode = detect_service_mode() if mode is None: diff --git a/tests/unit/api/test_update_router.py b/tests/unit/api/test_update_router.py index 08cbbbc9..ba18bac5 100644 --- a/tests/unit/api/test_update_router.py +++ b/tests/unit/api/test_update_router.py @@ -134,24 +134,130 @@ async def test_restart_endpoint_rejects_when_service_not_installed( async def test_restart_endpoint_desktop_schedules_process_exec( monkeypatch: pytest.MonkeyPatch, ) -> None: - called: list[bool] = [] + received: list[object] = [] monkeypatch.setattr(update_router, "_is_desktop_process", lambda: True) monkeypatch.setattr( update_router, "_restart_desktop_process", - lambda: called.append(True), + lambda server: received.append(server), ) from fastapi import BackgroundTasks + fake_server = object() bg = BackgroundTasks() - result = await update_router.restart_service_endpoint(bg, _=None) + result = await update_router.restart_service_endpoint(bg, server=fake_server, _=None) assert result == {"status": "restarting", "service_mode": "desktop"} - assert called == [] + assert received == [] for task in bg.tasks: await task() - assert called == [True] + assert received == [fake_server] + + +@pytest.mark.asyncio +async def test_restart_desktop_process_checkpoints_sqlite_wal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression for #800: desktop restart must flush WAL before execv.""" + checkpoints: list[str] = [] + closes: list[bool] = [] + exec_args: list[tuple[str, list[str]]] = [] + + class FakeConn: + def execute(self, sql: str) -> None: + checkpoints.append(sql) + + class FakePool: + dialect = "sqlite" + + def connect(self): + class Ctx: + def __enter__(_self): + return FakeConn() + + def __exit__(_self, *args): + return None + + return Ctx() + + def close(self) -> None: + closes.append(True) + + class FakeServices: + db = FakePool() + + class FakeServer: + services = FakeServices() + + monkeypatch.setattr( + update_router, + "os", + type("os", (), {"execv": lambda _self, p, a: exec_args.append((p, a))})(), + ) + monkeypatch.setattr( + update_router, + "sys", + type( + "sys", (), {"executable": "/bin/python", "argv": ["octop", "run"], "orig_argv": None} + )(), + ) + monkeypatch.setattr(update_router.time, "sleep", lambda _s: None) + + update_router._restart_desktop_process(FakeServer()) + + assert len(checkpoints) == 1 + assert "PRAGMA wal_checkpoint(TRUNCATE)" in checkpoints[0] + assert closes == [True] + assert len(exec_args) == 1 + + +@pytest.mark.asyncio +async def test_restart_desktop_process_swallows_checkpoint_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failing checkpoint should not prevent restart; data loss is logged.""" + exec_args: list[tuple[str, list[str]]] = [] + + class BadPool: + dialect = "sqlite" + + def connect(self): + class Ctx: + def __enter__(_self): + raise RuntimeError("db busy") + + def __exit__(_self, *args): + return None + + return Ctx() + + def close(self) -> None: + pass + + class FakeServices: + db = BadPool() + + class FakeServer: + services = FakeServices() + + monkeypatch.setattr( + update_router, + "os", + type("os", (), {"execv": lambda _self, p, a: exec_args.append((p, a))})(), + ) + monkeypatch.setattr( + update_router, + "sys", + type( + "sys", (), {"executable": "/bin/python", "argv": ["octop", "run"], "orig_argv": None} + )(), + ) + monkeypatch.setattr(update_router.time, "sleep", lambda _s: None) + + update_router._restart_desktop_process(FakeServer()) + + assert len(exec_args) == 1 @pytest.mark.asyncio From 0b2ac8b66e9e43343df94993c14a7d485d5ccec2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 23:42:50 +0800 Subject: [PATCH 02/39] fix(ollama): discover user-started local daemon --- CHANGELOG.md | 4 ++ src/octop/api/routers/ollama_models.py | 12 ++++-- src/octop/infra/utils/ollama_manager.py | 6 +-- tests/unit/api/test_ollama_service_default.py | 41 +++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f87fb586..1a7ed040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### Fixed + +- Detect models from an already-running Ollama daemon even when FreeOS-managed Ollama startup is disabled; model discovery never starts Ollama unless the user enabled it. + ## [0.0.10] - 2026-10-03 ### Fixed diff --git a/src/octop/api/routers/ollama_models.py b/src/octop/api/routers/ollama_models.py index 4d42e2c8..334f517e 100644 --- a/src/octop/api/routers/ollama_models.py +++ b/src/octop/api/routers/ollama_models.py @@ -81,15 +81,19 @@ async def list_ollama_models( _: Any = Depends(require_permission("ollama_models")), ) -> list[OllamaModelResponse]: """Return the current Ollama model list via the SDK.""" - if not _ollama_service_enabled(server): + from octop.infra.utils.ollama_manager import ( + OllamaModelManager, + is_ollama_reachable, + ) + + service_enabled = _ollama_service_enabled(server) + if not service_enabled and not is_ollama_reachable(): raise HTTPException( status_code=503, detail="Ollama service is disabled. Enable it from the provider card.", ) try: - from octop.infra.utils.ollama_manager import OllamaModelManager - - models = OllamaModelManager.list_models() + models = OllamaModelManager.list_models(start_if_needed=service_enabled) except (OSError, ImportError) as exc: logger.warning("Ollama bootstrap failed: %s", exc) raise HTTPException( diff --git a/src/octop/infra/utils/ollama_manager.py b/src/octop/infra/utils/ollama_manager.py index e29d0c3a..1558922c 100644 --- a/src/octop/infra/utils/ollama_manager.py +++ b/src/octop/infra/utils/ollama_manager.py @@ -387,9 +387,9 @@ class OllamaModelManager: """High-level wrapper around the Ollama SDK for model lifecycle.""" @staticmethod - def list_models() -> list[OllamaModelInfo]: - """Return the current model list from ``ollama.list()``.""" - ollama = _ensure_ollama() + def list_models(*, start_if_needed: bool = True) -> list[OllamaModelInfo]: + """Return models without starting Ollama unless explicitly allowed.""" + ollama = _ensure_ollama() if start_if_needed else _ensure_ollama_sdk() models: list[OllamaModelInfo] = [] for m in _iter_ollama_list_models(ollama.list()): name = str(m.get("model") or m.get("name") or "") diff --git a/tests/unit/api/test_ollama_service_default.py b/tests/unit/api/test_ollama_service_default.py index de913972..53370dda 100644 --- a/tests/unit/api/test_ollama_service_default.py +++ b/tests/unit/api/test_ollama_service_default.py @@ -5,6 +5,9 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest +from fastapi import HTTPException + from octop.api.routers import ollama_models @@ -18,3 +21,41 @@ def test_ollama_service_respects_stored_true() -> None: server = SimpleNamespace(services=SimpleNamespace(settings_repo=MagicMock())) server.services.settings_repo.get.return_value = "true" assert ollama_models._ollama_service_enabled(server) is True + + +@pytest.mark.asyncio +async def test_list_uses_running_daemon_when_service_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from octop.infra.utils import ollama_manager as om + from octop.infra.utils.ollama_manager import OllamaModelInfo + + monkeypatch.setattr(om, "is_ollama_reachable", lambda: True) + listed = MagicMock(return_value=[OllamaModelInfo(name="llama3.2:latest")]) + monkeypatch.setattr(om.OllamaModelManager, "list_models", listed) + server = SimpleNamespace(services=SimpleNamespace(settings_repo=MagicMock())) + server.services.settings_repo.get.return_value = "false" + + result = await ollama_models.list_ollama_models(server=server, _=None) + + assert [model.name for model in result] == ["llama3.2:latest"] + listed.assert_called_once_with(start_if_needed=False) + + +@pytest.mark.asyncio +async def test_list_does_not_start_unreachable_daemon_when_service_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from octop.infra.utils import ollama_manager as om + + monkeypatch.setattr(om, "is_ollama_reachable", lambda: False) + listed = MagicMock() + monkeypatch.setattr(om.OllamaModelManager, "list_models", listed) + server = SimpleNamespace(services=SimpleNamespace(settings_repo=MagicMock())) + server.services.settings_repo.get.return_value = "false" + + with pytest.raises(HTTPException) as exc_info: + await ollama_models.list_ollama_models(server=server, _=None) + + assert exc_info.value.status_code == 503 + listed.assert_not_called() From fff84117a1cf940ff22620ee2eedf9e756f268f9 Mon Sep 17 00:00:00 2001 From: HUANG Cheng Date: Thu, 17 Sep 2026 00:36:40 -0700 Subject: [PATCH 03/39] fix(config): never rewrite an unparseable config.json as empty (#730) (#738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(config): never rewrite an unparseable config.json as empty (#730) Four call sites read config.json, treated a parse failure as an empty dict and wrote that back, destroying every other key. One trailing comma plus one `octop run --port 8088` wiped 17 of 18 keys including the whole `database` section; the next boot silently came up as a greenfield SQLite instance (fresh setup-wizard password, zero warnings) while the PostgreSQL data sat unreachable. Losing `bind_host` the same way is what makes #730's reporter unable to reach the server from the LAN. A torn write creates the same precondition with no user error: three of the four sites wrote config.json with a bare write_text, so a crash / OOM / full disk mid-write left invalid JSON that the next plugin toggle then wiped. One shared policy in a new leaf helper, infra/utils/json_file.py: absent file -> {} and write (first run, unchanged) valid JSON object -> merge and write (unchanged) unparseable/non-object -> raise CONFIG_FILE_CORRUPT, write nothing The error names the file and the parser line/column but never the contents, which hold database credentials. Writes now go through write_json_atomic (temp + os.replace, fd closed on every path, temp removed on failure) so no reader can observe a torn file. Sites: cli/commands/run.py (_save_configfile_overrides, plus _load_configfile_overrides which used to fall back to launch defaults), infra/agents/plugins/manager.py (_write_global_plugin_enabled = the dashboard toggle), infra/setup/service.py (persist_bind_options), infra/agents/plugins/seed.py. `octop run` / `octop service start` report the error and exit 1 via fail_octop (now NoReturn) instead of a traceback. Supersedes the pinned "treat a malformed file as empty so the next run rewrites it cleanly" contract: its fixture (`{not valid json`) had nothing to lose, so the test could not observe the destruction. Rewritten as test_run_reports_corrupt_config_cleanly and test_run_does_not_overwrite_corrupt_config. Verified end-to-end against a real OctopServer + real PostgreSQL. Before: one `octop run --port` flipped driver postgresql -> sqlite, the instance reverted to the first-run wizard, and users=1/agents=1 were orphaned in PG with no warning. After: exit 1, config.json byte-identical, boot fails loudly instead of switching backends. Mutation check: restoring the lenient policy in the helper reddens 9 tests spanning all four sites. make all green (3299 passed, 16 skipped); mypy --strict clean; dashboard `npx tsc --noEmit` clean. * fix(config): name the file when config.json is corrupt (#730) `load_config` let a bare `json.JSONDecodeError` escape: it reports a line and column but never says *which* file failed, and this entry point is reached from boot, backup and several CLI commands — not only `octop run`. Raise a ValueError naming the path and the parser position, refuse a non-object document, and never echo file contents (config.json holds database credentials). Same policy as infra/utils/json_file.py, duplicated here because octop.config must stay free of infra imports (AGENTS.md §5). `octop run` already exits cleanly on a corrupt config through resolve_bind; this covers the remaining entry points. make all green (3301 passed, 16 skipped); mypy --strict clean. * fix(config): preserve config.json permissions in atomic writes Self-review against CONTRIBUTING.md / AGENTS.md §7 caught a regression in the atomic writer added earlier in this branch: tempfile.mkstemp creates the temp file 0600, so replacing config.json silently tightened an existing 0644 file to 0600. Under `octop service start --scope system` that runs as root and writes into the service user's home, a root-owned 0600 config.json is unreadable by the service user and the unit cannot start; the bare write_text it replaced produced the umask default. write_json_atomic now chmods the temp file to the mode of the file being replaced, or to `0o666 & ~umask` for a new file — matching the two pre-existing config.json writers (infra/db/rebind.py, infra/backup/auto.py; both write_text + replace) rather than mkstemp's 0600. POSIX-guarded, so Windows behaviour is unchanged and no test asserts mode bits off POSIX (AGENTS.md §7). Also adds the missing CHANGELOG line for the load_config error-message change. Verified: new file -> 0644, existing 0644 -> 0644, existing 0600 -> 0600. make all green (3303 passed, 16 skipped); make typecheck-frontend green; eslint 0 errors (67 pre-existing react-hooks warnings). Note: `make check-all` is red on develop itself — prettier --check fails on dashboard/src/pages/Agent/Skills/components/SkillsTabs.tsx exactly as committed by 83378876. Left untouched here; confirmed pre-existing by checking develop's version of that file in isolation. --- CHANGELOG.md | 1 + dashboard/src/locales/en.json | 3 +- dashboard/src/locales/zh.json | 3 +- src/octop/cli/commands/run.py | 99 +++++++++---------- src/octop/cli/commands/service.py | 13 ++- src/octop/cli/support/errors.py | 4 +- src/octop/config.py | 22 ++++- src/octop/i18n/en.json | 3 +- src/octop/i18n/zh.json | 3 +- src/octop/infra/agents/plugins/manager.py | 31 +++--- src/octop/infra/agents/plugins/seed.py | 27 +++-- src/octop/infra/errors.py | 11 +++ src/octop/infra/setup/service.py | 30 +++--- src/octop/infra/utils/json_file.py | 109 +++++++++++++++++++++ tests/unit/cli/test_run_bind_resolution.py | 78 ++++++++++++++- tests/unit/cli/test_run_cmd.py | 49 ++++++--- tests/unit/i18n/test_errors.py | 9 ++ tests/unit/infra/setup/test_service.py | 13 +++ tests/unit/infra/utils/test_json_file.py | 107 ++++++++++++++++++++ tests/unit/test_config.py | 22 +++++ tests/unit/test_plugin_manager.py | 35 +++++++ tests/unit/test_plugin_seed.py | 18 ++++ 22 files changed, 577 insertions(+), 113 deletions(-) create mode 100644 src/octop/infra/utils/json_file.py create mode 100644 tests/unit/infra/utils/test_json_file.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7ed040..95e96071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Detect models from an already-running Ollama daemon even when FreeOS-managed Ollama startup is disabled; model discovery never starts Ollama unless the user enabled it. +- Preserve `config.json` when it is malformed: startup and all configuration writers now report the file and parser position, avoid leaking stored credentials, and use atomic replacement instead of silently rebuilding an empty configuration. ## [0.0.10] - 2026-10-03 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index d361d312..a80b207a 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -440,7 +440,8 @@ "INVITE_RATE_LIMITED": "Too many invite attempts. Try again later.", "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({{used}}/{{quota}}).", - "LOCAL_MODEL_REGISTER_FAILED": "Could not register the local model: {{reason}}" + "LOCAL_MODEL_REGISTER_FAILED": "Could not register the local model: {{reason}}", + "CONFIG_FILE_CORRUPT": "Cannot update {{path}}: the file is not valid JSON ({{detail}}). Fix the syntax and retry — no settings were changed." }, "nav": { "chat": "Conversation", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 3cc0ad3e..edb2a764 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -440,7 +440,8 @@ "INVITE_RATE_LIMITED": "邀请尝试次数过多,请稍后再试。", "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({{used}}/{{quota}})。", - "LOCAL_MODEL_REGISTER_FAILED": "无法注册本地模型:{{reason}}" + "LOCAL_MODEL_REGISTER_FAILED": "无法注册本地模型:{{reason}}", + "CONFIG_FILE_CORRUPT": "无法更新 {{path}}:该文件不是合法 JSON({{detail}})。请修正语法后重试;现有配置未被改动。" }, "nav": { "chat": "对话", diff --git a/src/octop/cli/commands/run.py b/src/octop/cli/commands/run.py index 643cf90e..5ce91c7c 100644 --- a/src/octop/cli/commands/run.py +++ b/src/octop/cli/commands/run.py @@ -2,15 +2,16 @@ from __future__ import annotations -import contextlib -import json -import os -import tempfile -from pathlib import Path - import click +from octop.cli.support.errors import fail_octop from octop.config import env_bind_overrides +from octop.infra.errors import OctopError, corrupt_config_error +from octop.infra.utils.json_file import ( + JsonFileCorruptError, + read_json_object, + write_json_atomic, +) from octop.infra.utils.paths import PathLayout @@ -69,20 +70,29 @@ def _maybe_generate_self_signed( def _load_configfile_overrides() -> tuple[str | None, int | None]: + """Read ``bind_host`` / ``port`` from ``config.json``. + + An absent or unreadable file falls back to the launch defaults, but a + *corrupt* one raises: silently ignoring it would bind defaults while + discarding every other setting, and boot would then die with a bare + ``JSONDecodeError`` traceback instead of an actionable message. + """ cfg_path = PathLayout.from_env().config - if not cfg_path.exists(): + try: + data = read_json_object(cfg_path) + except JsonFileCorruptError as exc: + raise corrupt_config_error(exc.path, exc.detail) from exc + except OSError: return None, None - with contextlib.suppress(OSError, json.JSONDecodeError): - data = json.loads(cfg_path.read_text(encoding="utf-8")) - if isinstance(data, dict): - # Prefer new key "bind_host"; fall back to legacy "host" for old configs. - host = data.get("bind_host") or data.get("host") - port = data.get("port") - return ( - host if isinstance(host, str) else None, - port if isinstance(port, int) and not isinstance(port, bool) else None, - ) - return None, None + if data is None: + return None, None + # Prefer new key "bind_host"; fall back to legacy "host" for old configs. + host = data.get("bind_host") or data.get("host") + port = data.get("port") + return ( + host if isinstance(host, str) else None, + port if isinstance(port, int) and not isinstance(port, bool) else None, + ) def resolve_bind(host: str | None, port: int | None) -> tuple[str | None, int | None]: @@ -96,48 +106,30 @@ def resolve_bind(host: str | None, port: int | None) -> tuple[str | None, int | return resolved_host, resolved_port -def _atomic_write_json(path: Path, data: dict[str, object]) -> None: - """Write JSON atomically: temp file + ``os.replace`` to avoid torn writes. +def _save_configfile_overrides(host: str | None, port: int | None) -> None: + """Persist resolved host/port to config.json (read-merge-write, atomic). - Uses ``os.write`` with pre-encoded bytes (instead of ``os.fdopen``) so the - file descriptor is owned and closed deterministically, even if encoding - is unavailable or the process is interrupted mid-write. + A corrupt config.json is a hard error, never an empty base: merging into + ``{}`` and writing back destroys every other setting (issue #730 — one + trailing comma plus ``octop run --port`` wiped the ``database`` section and + silently flipped a PostgreSQL instance back to greenfield SQLite). """ - payload = (json.dumps(data, indent=2) + "\n").encode("utf-8") - fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=".config.", suffix=".tmp") - try: - try: - os.write(fd, payload) - finally: - # Always close — covers the case where ``os.write`` itself raises - # (disk full, EIO, EPIPE) so the fd does not leak. - os.close(fd) - os.replace(tmp_name, path) - except BaseException: # also covers KeyboardInterrupt / SystemExit - # Best-effort cleanup of the temp file on any failure path. - with contextlib.suppress(OSError): - os.unlink(tmp_name) - raise - - -def _save_configfile_overrides(host: str | None, port: int | None) -> None: - """Persist resolved host/port to config.json (read-merge-write, atomic).""" paths = PathLayout.from_env() paths.ensure_root() cfg_path = paths.config - data: dict[str, object] = {} - if cfg_path.exists(): - with contextlib.suppress(OSError, json.JSONDecodeError): - loaded = json.loads(cfg_path.read_text(encoding="utf-8")) - if isinstance(loaded, dict): - data = loaded + try: + data = read_json_object(cfg_path) + except JsonFileCorruptError as exc: + raise corrupt_config_error(exc.path, exc.detail) from exc + if data is None: + data = {} if host is not None: data["bind_host"] = host if port is not None: data["port"] = port click.echo(f"Saved config to {cfg_path}") try: - _atomic_write_json(cfg_path, data) + write_json_atomic(cfg_path, data) except OSError as exc: click.echo(f"Warning: could not save config.json: {exc}", err=True) @@ -181,9 +173,12 @@ def run( starts. """ cli_host, cli_port = host, port - host, port = resolve_bind(host, port) - if cli_host is not None or cli_port is not None: - _save_configfile_overrides(cli_host, cli_port) + try: + host, port = resolve_bind(host, port) + if cli_host is not None or cli_port is not None: + _save_configfile_overrides(cli_host, cli_port) + except OctopError as exc: + fail_octop(exc) certfile, keyfile = _maybe_generate_self_signed(ssl, ssl_certfile, ssl_keyfile) _run_uvicorn( host=host, diff --git a/src/octop/cli/commands/service.py b/src/octop/cli/commands/service.py index b23f9af5..168a9cbd 100644 --- a/src/octop/cli/commands/service.py +++ b/src/octop/cli/commands/service.py @@ -2,8 +2,11 @@ from __future__ import annotations +from typing import NoReturn + import click +from octop.infra.errors import OctopError from octop.infra.setup.service import ( DEFAULT_HEALTH_ATTEMPTS, DEFAULT_HEALTH_DELAY_SECONDS, @@ -35,7 +38,7 @@ def _runtime(host: str | None, port: int | None, scope: ServiceScope | None) -> raise SystemExit(1) from exc -def _fail(exc: Exception) -> None: +def _fail(exc: Exception) -> NoReturn: click.echo(f"error: {exc}", err=True) raise SystemExit(1) from exc @@ -125,13 +128,13 @@ def _echo_summary_for(runtime: ServiceRuntime, *, warn_health: bool) -> None: def start(host: str | None, port: int | None, force_install: bool, scope: str) -> None: """Install if needed, ensure the NOFILE drop-in, then start the service.""" runtime = _runtime(host, port, _resolve_scope(scope)) - if host is not None or port is not None: - persist_bind_options(runtime.home, host=host, port=port) - runtime = _runtime(host, port, _resolve_scope(scope)) try: + if host is not None or port is not None: + persist_bind_options(runtime.home, host=host, port=port) + runtime = _runtime(host, port, _resolve_scope(scope)) wrote = install_service(runtime, force=force_install) start_service(runtime, apply_unit=wrote) - except RuntimeError as exc: + except (RuntimeError, OctopError) as exc: _fail(exc) _echo_summary_for(runtime, warn_health=True) diff --git a/src/octop/cli/support/errors.py b/src/octop/cli/support/errors.py index efa4f2b3..d445a176 100644 --- a/src/octop/cli/support/errors.py +++ b/src/octop/cli/support/errors.py @@ -2,11 +2,13 @@ from __future__ import annotations +from typing import NoReturn + import click from octop.infra.errors import OctopError -def fail_octop(exc: OctopError) -> None: +def fail_octop(exc: OctopError) -> NoReturn: click.echo(f"error: {exc.message}", err=True) raise SystemExit(1) from exc diff --git a/src/octop/config.py b/src/octop/config.py index 8a396882..4d61334a 100644 --- a/src/octop/config.py +++ b/src/octop/config.py @@ -410,10 +410,28 @@ def env_bind_overrides() -> tuple[str | None, int | None]: def load_config(path: Path) -> OctopConfig: - """Load ``config.json``; write defaults if absent. Apply env overrides.""" + """Load ``config.json``; write defaults if absent. Apply env overrides. + + A corrupt file raises with the path and parser position named, and is never + treated as empty: this is the same policy as ``infra/utils/json_file.py``, + duplicated here because ``octop.config`` must stay free of ``infra`` imports + (AGENTS.md §5). The message never echoes file contents — they hold database + credentials. + """ file_defaults = _defaults_for_file() if path.exists(): - raw = json.loads(path.read_text(encoding="utf-8")) + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError( + f"{path} is not valid JSON (line {exc.lineno}, column {exc.colno});" + " fix it and retry — no settings were changed" + ) from exc + if not isinstance(raw, dict): + raise ValueError( + f"{path} must contain a JSON object, got {type(raw).__name__};" + " fix it and retry — no settings were changed" + ) database_in_file = "database" in raw else: path.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index ab42ce25..a51f35e9 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -405,7 +405,8 @@ "INVITE_RATE_LIMITED": "Too many invite attempts. Try again later.", "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({used}/{quota}).", - "LOCAL_MODEL_REGISTER_FAILED": "Could not register the local model: {reason}" + "LOCAL_MODEL_REGISTER_FAILED": "Could not register the local model: {reason}", + "CONFIG_FILE_CORRUPT": "Cannot update {path}: the file is not valid JSON ({detail}). Fix the syntax and retry — no settings were changed." }, "desktop": { "install_log_deps": "Installing Python dependencies…", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 9ed4b607..0f9f7c5d 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -405,7 +405,8 @@ "INVITE_RATE_LIMITED": "邀请尝试次数过多,请稍后再试。", "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({used}/{quota})。", - "LOCAL_MODEL_REGISTER_FAILED": "无法注册本地模型:{reason}" + "LOCAL_MODEL_REGISTER_FAILED": "无法注册本地模型:{reason}", + "CONFIG_FILE_CORRUPT": "无法更新 {path}:该文件不是合法 JSON({detail})。请修正语法后重试;现有配置未被改动。" }, "desktop": { "install_log_deps": "正在安装 Python 依赖…", diff --git a/src/octop/infra/agents/plugins/manager.py b/src/octop/infra/agents/plugins/manager.py index 56596498..1ad51d27 100644 --- a/src/octop/infra/agents/plugins/manager.py +++ b/src/octop/infra/agents/plugins/manager.py @@ -24,7 +24,12 @@ unload_plugin, ) -from octop.infra.errors import ErrorCode, OctopError +from octop.infra.errors import ErrorCode, OctopError, corrupt_config_error +from octop.infra.utils.json_file import ( + JsonFileCorruptError, + read_json_object, + write_json_atomic, +) logger = logging.getLogger(__name__) @@ -73,15 +78,18 @@ def _read_global_plugins(config_path: Path) -> dict[str, bool]: def _write_global_plugin_enabled(config_path: Path, plugin_id: str, enabled: bool) -> None: - """Merge ``plugins..enabled`` into ``config.json`` without dropping other keys.""" - data: dict[str, Any] = {} - if config_path.is_file(): - try: - raw = json.loads(config_path.read_text(encoding="utf-8")) - if isinstance(raw, dict): - data = raw - except Exception: - data = {} + """Merge ``plugins..enabled`` into ``config.json`` without dropping other keys. + + A corrupt config.json raises instead of being treated as empty: merging into + ``{}`` and writing back destroys every other setting, including the + ``database`` section (issue #730). + """ + try: + data = read_json_object(config_path) + except JsonFileCorruptError as exc: + raise corrupt_config_error(exc.path, exc.detail) from exc + if data is None: + data = {} plugins = data.get("plugins") if not isinstance(plugins, dict): plugins = {} @@ -91,8 +99,7 @@ def _write_global_plugin_enabled(config_path: Path, plugin_id: str, enabled: boo entry = {} entry["enabled"] = bool(enabled) plugins[plugin_id] = entry - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + write_json_atomic(config_path, data) def _assert_http_url(url: str) -> None: diff --git a/src/octop/infra/agents/plugins/seed.py b/src/octop/infra/agents/plugins/seed.py index ba2dcbac..9fa79a61 100644 --- a/src/octop/infra/agents/plugins/seed.py +++ b/src/octop/infra/agents/plugins/seed.py @@ -2,27 +2,36 @@ from __future__ import annotations -import json import shutil from pathlib import Path from typing import Any import yaml +from octop.infra.errors import corrupt_config_error +from octop.infra.utils.json_file import ( + JsonFileCorruptError, + read_json_object, + write_json_atomic, +) + def _read_config(config_path: Path) -> dict[str, Any]: - if not config_path.is_file(): - return {} + """Load ``config.json`` for merging; ``{}`` when absent. + + A corrupt file raises instead of reading as empty: ``seed_bundled_plugins`` + always writes back, so merging into ``{}`` would destroy every other setting + (issue #730). + """ try: - raw = json.loads(config_path.read_text(encoding="utf-8")) - except Exception: - return {} - return raw if isinstance(raw, dict) else {} + data = read_json_object(config_path) + except JsonFileCorruptError as exc: + raise corrupt_config_error(exc.path, exc.detail) from exc + return data if data is not None else {} def _write_config(config_path: Path, data: dict[str, Any]) -> None: - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + write_json_atomic(config_path, data) def _plugin_version(plugin_dir: Path) -> tuple[int, ...]: diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 23344cc8..5033812c 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -110,6 +110,7 @@ class ErrorCode(StrEnum): WORKSPACE_ROOT_RESTRICTED = "WORKSPACE_ROOT_RESTRICTED" TOKEN_QUOTA_EXCEEDED = "TOKEN_QUOTA_EXCEEDED" LOCAL_MODEL_REGISTER_FAILED = "LOCAL_MODEL_REGISTER_FAILED" + CONFIG_FILE_CORRUPT = "CONFIG_FILE_CORRUPT" _DEFAULT_STATUS: dict[ErrorCode, int] = { @@ -211,6 +212,7 @@ class ErrorCode(StrEnum): ErrorCode.WORKSPACE_ROOT_RESTRICTED: 400, ErrorCode.TOKEN_QUOTA_EXCEEDED: 403, ErrorCode.LOCAL_MODEL_REGISTER_FAILED: 400, + ErrorCode.CONFIG_FILE_CORRUPT: 400, } @@ -260,3 +262,12 @@ def to_envelope(self, *, locale: str | Locale | None = None) -> dict[str, Any]: "details": self.details, } } + + +def corrupt_config_error(path: object, detail: str) -> OctopError: + """Report invalid JSON without exposing configuration contents.""" + return OctopError( + ErrorCode.CONFIG_FILE_CORRUPT, + f"{path} is not valid JSON ({detail}); fix it and retry — no settings were changed", + details={"path": str(path), "detail": detail}, + ) diff --git a/src/octop/infra/setup/service.py b/src/octop/infra/setup/service.py index a81abfee..f84fc77a 100644 --- a/src/octop/infra/setup/service.py +++ b/src/octop/infra/setup/service.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import logging import os import platform @@ -11,13 +10,18 @@ import sys import tempfile import time -from contextlib import suppress from dataclasses import dataclass from pathlib import Path from typing import Literal from octop.config import load_config +from octop.infra.errors import corrupt_config_error from octop.infra.utils import posix_compat as pwd +from octop.infra.utils.json_file import ( + JsonFileCorruptError, + read_json_object, + write_json_atomic, +) from octop.infra.utils.paths import PathLayout from octop.infra.utils.posix_compat import chown, geteuid, getuid, is_root @@ -232,22 +236,26 @@ def resolve_service_home(*, run_as_user: str | None = None) -> Path: def persist_bind_options(home: Path, *, host: str | None = None, port: int | None = None) -> None: - """Write bind host/port into ``config.json`` before (re)installing the unit.""" + """Write bind host/port into ``config.json`` before (re)installing the unit. + + Raises on a corrupt config.json rather than treating it as empty — writing a + merged-into-``{}`` dict would destroy every other setting, including the + ``database`` section (issue #730). + """ if host is None and port is None: return config_path = home / "config.json" - data: dict[str, object] = {} - if config_path.is_file(): - with suppress(OSError, json.JSONDecodeError): - loaded = json.loads(config_path.read_text(encoding="utf-8")) - if isinstance(loaded, dict): - data = loaded + try: + data = read_json_object(config_path) + except JsonFileCorruptError as exc: + raise corrupt_config_error(exc.path, exc.detail) from exc + if data is None: + data = {} if host is not None: data["bind_host"] = host if port is not None: data["port"] = port - home.mkdir(parents=True, exist_ok=True) - config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + write_json_atomic(config_path, data) def resolve_bind_options(home: Path | None = None) -> tuple[str, int]: diff --git a/src/octop/infra/utils/json_file.py b/src/octop/infra/utils/json_file.py new file mode 100644 index 00000000..184904d4 --- /dev/null +++ b/src/octop/infra/utils/json_file.py @@ -0,0 +1,109 @@ +"""Safe read/write helpers for the small JSON config files under ``OCTOP_HOME``. + +Leaf module (AGENTS.md §5): stdlib only, no ``infra`` imports. Callers translate +:class:`JsonFileCorruptError` into ``OctopError`` via +``octop.infra.errors.corrupt_config_error``. + +Both helpers exist because of issue #730: four call sites used to treat an +unparseable ``config.json`` as an empty dict and then wrote that back, silently +destroying every other key. One trailing comma plus one ``octop run --port`` +wiped the ``database`` section and flipped a running PostgreSQL instance back to +a greenfield SQLite one, with no warning in the log. A torn write from a crash or +a full disk creates the same precondition without any user error, so writes here +are atomic too. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import stat +import tempfile +from pathlib import Path +from typing import Any + +__all__ = ["JsonFileCorruptError", "read_json_object", "write_json_atomic"] + + +def _default_file_mode() -> int: + """The mode a plain ``write_text`` would produce (``0o666 & ~umask``). + + POSIX has no get-only umask call, so read it by set-and-restore. Matches the + two pre-existing config.json writers (``db/rebind.py``, ``backup/auto.py``) + instead of leaving ``mkstemp``'s 0600, which would make a root-created + config.json unreadable to the service user after + ``octop service start --scope system``. + """ + mask = os.umask(0o022) + os.umask(mask) + return 0o666 & ~mask + + +class JsonFileCorruptError(ValueError): + """A JSON file exists but cannot be parsed into an object. + + Carries the path and the parser position only — never the file contents or + any value from them, since ``config.json`` holds database credentials and + secrets (precedent: ``octop.config`` refuses to log raw env values). + """ + + def __init__(self, path: Path, detail: str) -> None: + self.path = path + self.detail = detail + super().__init__(f"{path} is not valid JSON ({detail})") + + +def read_json_object(path: Path) -> dict[str, Any] | None: + """Return the JSON object at ``path``, or ``None`` when the file is absent. + + Raises :class:`JsonFileCorruptError` when the file exists but is not valid + JSON or is not a JSON object. Callers must **not** fall back to an empty + dict: merging into ``{}`` and writing back is what destroys every other + setting. An absent file is legitimate (first run) and yields ``None``. + """ + if not path.is_file(): + return None + try: + raw = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise JsonFileCorruptError(path, "not valid UTF-8") from exc + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise JsonFileCorruptError(path, f"line {exc.lineno}, column {exc.colno}") from exc + if not isinstance(parsed, dict): + raise JsonFileCorruptError(path, f"expected a JSON object, got {type(parsed).__name__}") + return parsed + + +def write_json_atomic(path: Path, data: dict[str, Any]) -> None: + """Write ``data`` as pretty JSON via temp file + ``os.replace``. + + Readers never observe a partially written file, so an interrupted write + cannot leave the truncated JSON that :func:`read_json_object` then refuses + to merge. The temp file is unique per call (``mkstemp``), so concurrent + writers cannot share one ``.tmp`` name. + + Permissions follow the file being replaced, or the process umask for a new + file — a config write must never silently tighten or loosen the mode of a + file the user already had. + """ + payload = (json.dumps(data, indent=2) + "\n").encode("utf-8") + path.parent.mkdir(parents=True, exist_ok=True) + existing_mode: int | None = None + if os.name == "posix" and path.exists(): + existing_mode = stat.S_IMODE(path.stat().st_mode) + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + try: + os.write(fd, payload) + finally: + os.close(fd) + if os.name == "posix": + os.chmod(tmp_name, _default_file_mode() if existing_mode is None else existing_mode) + os.replace(tmp_name, path) + except BaseException: # also covers KeyboardInterrupt / SystemExit + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise diff --git a/tests/unit/cli/test_run_bind_resolution.py b/tests/unit/cli/test_run_bind_resolution.py index ff7c72ac..a5985647 100644 --- a/tests/unit/cli/test_run_bind_resolution.py +++ b/tests/unit/cli/test_run_bind_resolution.py @@ -4,10 +4,13 @@ import json from pathlib import Path +from unittest.mock import patch import pytest +from click.testing import CliRunner -from octop.cli.commands.run import resolve_bind +from octop.cli.commands.run import _save_configfile_overrides, resolve_bind, run +from octop.infra.errors import ErrorCode, OctopError @pytest.fixture @@ -55,3 +58,76 @@ def test_invalid_env_port_falls_back_to_file( def test_legacy_host_key(octop_home: Path) -> None: _write_config(octop_home, host="192.168.1.10", port=1234) assert resolve_bind(None, None) == ("192.168.1.10", 1234) + + +# --- issue #730: a corrupt config.json must never be treated as empty -------- + + +def _corrupt(text: str) -> str: + """Append a trailing comma before the closing brace (classic hand-edit slip).""" + stripped = text.rstrip() + assert stripped.endswith("}") + return stripped[:-1].rstrip() + ",}" + + +def test_corrupt_config_raises_instead_of_silently_using_defaults(octop_home: Path) -> None: + (octop_home / "config.json").write_text('{"bind_host": "0.0.0.0",}', encoding="utf-8") + with pytest.raises(OctopError) as excinfo: + resolve_bind(None, None) + assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT + + +def test_save_overrides_on_corrupt_config_preserves_bytes(octop_home: Path) -> None: + cfg = octop_home / "config.json" + corrupt = _corrupt( + json.dumps({"bind_host": "0.0.0.0", "database": {"driver": "postgresql"}}, indent=2) + ) + cfg.write_text(corrupt, encoding="utf-8") + with pytest.raises(OctopError) as excinfo: + _save_configfile_overrides(None, 8088) + assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT + assert cfg.read_text(encoding="utf-8") == corrupt + + +def test_save_overrides_merges_without_dropping_keys(octop_home: Path) -> None: + cfg = octop_home / "config.json" + cfg.write_text( + json.dumps( + { + "bind_host": "0.0.0.0", + "database": {"driver": "postgresql", "host": "db.internal"}, + "log_level": "debug", + } + ), + encoding="utf-8", + ) + _save_configfile_overrides(None, 8088) + data = json.loads(cfg.read_text(encoding="utf-8")) + assert data["port"] == 8088 + assert data["bind_host"] == "0.0.0.0" + assert data["database"] == {"driver": "postgresql", "host": "db.internal"} + assert data["log_level"] == "debug" + + +def test_run_cli_on_corrupt_config_refuses_and_preserves_file(octop_home: Path) -> None: + """POC regression: ``octop run --port`` used to wipe config.json to one key.""" + cfg = octop_home / "config.json" + cfg.write_text( + json.dumps( + { + "bind_host": "0.0.0.0", + "database": {"driver": "postgresql", "host": "db.internal"}, + }, + indent=2, + ), + encoding="utf-8", + ) + corrupt = _corrupt(cfg.read_text(encoding="utf-8")) + cfg.write_text(corrupt, encoding="utf-8") + + with patch("octop.cli.commands.run._run_uvicorn") as mocked: + result = CliRunner().invoke(run, ["--port", "8088"]) + + assert result.exit_code != 0 + assert not mocked.called + assert cfg.read_text(encoding="utf-8") == corrupt diff --git a/tests/unit/cli/test_run_cmd.py b/tests/unit/cli/test_run_cmd.py index 445e66b8..341b49bc 100644 --- a/tests/unit/cli/test_run_cmd.py +++ b/tests/unit/cli/test_run_cmd.py @@ -2,8 +2,10 @@ from __future__ import annotations +import contextlib import json from pathlib import Path +from typing import Any import pytest from click.testing import CliRunner @@ -182,10 +184,22 @@ def _fake(**_kw: object) -> None: assert data == {"theme": "dark", "bind_host": "0.0.0.0", "port": 80} -def test_run_handles_corrupt_config_without_crashing( +def _all_output(result: Any) -> str: + """stdout + stderr across click versions (8.2+ can separate the streams).""" + text = result.output or "" + with contextlib.suppress(ValueError, AttributeError): + text += result.stderr + return text + + +def test_run_reports_corrupt_config_cleanly( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """A malformed config.json must not block the run command.""" + """A malformed config.json must fail cleanly, not with a traceback (#730). + + The command cannot guess the ~18 settings the file holds, and boot would die + in ``load_config`` anyway, so the CLI names the file and position and stops. + """ _patch_home(monkeypatch, tmp_path) (tmp_path / "config.json").write_text("{not valid json", encoding="utf-8") @@ -200,22 +214,28 @@ def _fake(**kw: object) -> None: runner = CliRunner() r = runner.invoke(cli, ["run", "--port", "80"]) - assert r.exit_code == 0, r.output - # Falls back to launch defaults for host, CLI for port. - assert captured["port"] == 80 - assert captured["host"] is None + assert r.exit_code == 1, _all_output(r) + # Never starts with the settings it could not read. + assert captured == {} + out = _all_output(r) + assert "Traceback" not in out + assert "not valid JSON" in out -def test_run_overwrites_corrupt_config_with_resolved_values( +def test_run_does_not_overwrite_corrupt_config( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """A corrupt config.json is replaced with the resolved values on save. + """A corrupt config.json is preserved byte-for-byte, never rewritten as empty. - Documents the deliberate ``read-merge-write`` contract: a malformed file - is treated as empty, so the next run rewrites it cleanly. + Supersedes the old "treat a malformed file as empty so the next run rewrites + it cleanly" contract. That rewrite destroyed every other setting: one + trailing comma plus ``octop run --port`` wiped the ``database`` section and + silently flipped a PostgreSQL instance back to a greenfield SQLite one, with + no warning in the log (issue #730). """ _patch_home(monkeypatch, tmp_path) - (tmp_path / "config.json").write_text("{not valid json", encoding="utf-8") + corrupt = '{"bind_host": "0.0.0.0", "database": {"driver": "postgresql"},}' + (tmp_path / "config.json").write_text(corrupt, encoding="utf-8") def _fake(**_kw: object) -> None: return None @@ -226,11 +246,8 @@ def _fake(**_kw: object) -> None: runner = CliRunner() r = runner.invoke(cli, ["run", "--host", "0.0.0.0", "--port", "80"]) - assert r.exit_code == 0, r.output - - config_path = tmp_path / "config.json" - data = json.loads(config_path.read_text(encoding="utf-8")) - assert data == {"bind_host": "0.0.0.0", "port": 80} + assert r.exit_code == 1, _all_output(r) + assert (tmp_path / "config.json").read_text(encoding="utf-8") == corrupt def test_run_port_zero_not_overridden_by_config( diff --git a/tests/unit/i18n/test_errors.py b/tests/unit/i18n/test_errors.py index a66ff708..377a9848 100644 --- a/tests/unit/i18n/test_errors.py +++ b/tests/unit/i18n/test_errors.py @@ -94,3 +94,12 @@ def test_localized_message_falls_back_when_key_missing(monkeypatch: pytest.Monke lambda *_a, **_k: (_ for _ in ()).throw(KeyError("errors.X")), ) assert err.localized_message("zh") == "custom detail" + + +def test_config_file_corrupt_interpolates_path_and_detail(): + kwargs = {"path": "~/.octop/config.json", "detail": "line 3, column 1"} + assert "~/.octop/config.json" in error_message("CONFIG_FILE_CORRUPT", "en", **kwargs) + assert "line 3, column 1" in error_message("CONFIG_FILE_CORRUPT", "zh", **kwargs) + for locale in ("en", "zh"): + msg = error_message("CONFIG_FILE_CORRUPT", locale, **kwargs) + assert "{path}" not in msg and "{detail}" not in msg diff --git a/tests/unit/infra/setup/test_service.py b/tests/unit/infra/setup/test_service.py index 72129bee..ddcc33a5 100644 --- a/tests/unit/infra/setup/test_service.py +++ b/tests/unit/infra/setup/test_service.py @@ -12,6 +12,7 @@ import pytest +from octop.infra.errors import ErrorCode, OctopError from octop.infra.setup import service as service_mod from octop.infra.setup.service import ( ServiceRuntime, @@ -864,3 +865,15 @@ def access(self, _path: object, _mode: int) -> bool: def unlink(self, _path: object) -> None: # The tmp file is `delete=False`, so this is called in `finally`. return None + + +def test_persist_bind_options_refuses_corrupt_config(tmp_path: Path) -> None: + """issue #730: must not merge into an empty dict and wipe every other key.""" + home = tmp_path / "octop" + home.mkdir() + corrupt = '{"bind_host": "0.0.0.0", "database": {"driver": "postgresql"},}' + (home / "config.json").write_text(corrupt, encoding="utf-8") + with pytest.raises(OctopError) as excinfo: + service_mod.persist_bind_options(home, host="127.0.0.1", port=443) + assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT + assert (home / "config.json").read_text(encoding="utf-8") == corrupt diff --git a/tests/unit/infra/utils/test_json_file.py b/tests/unit/infra/utils/test_json_file.py new file mode 100644 index 00000000..f4506c27 --- /dev/null +++ b/tests/unit/infra/utils/test_json_file.py @@ -0,0 +1,107 @@ +"""Unit tests for the shared JSON config helpers (issue #730).""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path + +import pytest + +from octop.infra.utils.json_file import ( + JsonFileCorruptError, + read_json_object, + write_json_atomic, +) + +posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX file modes only") + + +def test_read_absent_file_returns_none(tmp_path: Path) -> None: + assert read_json_object(tmp_path / "missing.json") is None + + +def test_read_valid_object(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text(json.dumps({"bind_host": "0.0.0.0", "port": 8088}), encoding="utf-8") + assert read_json_object(path) == {"bind_host": "0.0.0.0", "port": 8088} + + +def test_read_corrupt_raises_with_position_and_never_leaks_contents(tmp_path: Path) -> None: + path = tmp_path / "config.json" + # Trailing comma — the classic hand-edit slip. The password must not surface. + path.write_text('{"database": {"password": "s3cret"},}', encoding="utf-8") + with pytest.raises(JsonFileCorruptError) as excinfo: + read_json_object(path) + err = excinfo.value + assert err.path == path + assert "line" in err.detail and "column" in err.detail + assert "s3cret" not in err.detail + assert "s3cret" not in str(err) + + +def test_read_non_object_raises(tmp_path: Path) -> None: + path = tmp_path / "config.json" + path.write_text("[1, 2]", encoding="utf-8") + with pytest.raises(JsonFileCorruptError) as excinfo: + read_json_object(path) + assert "list" in excinfo.value.detail + + +def test_write_is_pretty_newline_terminated_and_leaves_no_temp_files(tmp_path: Path) -> None: + path = tmp_path / "config.json" + write_json_atomic(path, {"a": 1}) + text = path.read_text(encoding="utf-8") + assert json.loads(text) == {"a": 1} + assert text.endswith("\n") + assert [p.name for p in tmp_path.iterdir()] == ["config.json"] + + +def test_write_creates_missing_parent(tmp_path: Path) -> None: + path = tmp_path / "nested" / "config.json" + write_json_atomic(path, {"a": 1}) + assert json.loads(path.read_text(encoding="utf-8")) == {"a": 1} + + +def test_failed_replace_leaves_original_intact_and_cleans_temp( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A torn write must not be observable: the old file survives byte-for-byte.""" + path = tmp_path / "config.json" + original = '{"bind_host": "0.0.0.0", "database": {"driver": "postgresql"}}' + path.write_text(original, encoding="utf-8") + + def boom(*_args: object, **_kwargs: object) -> None: + raise OSError("disk full") + + monkeypatch.setattr("octop.infra.utils.json_file.os.replace", boom) + with pytest.raises(OSError, match="disk full"): + write_json_atomic(path, {"port": 1}) + assert path.read_text(encoding="utf-8") == original + assert [p.name for p in tmp_path.iterdir()] == ["config.json"] + + +@posix_only +def test_write_preserves_existing_file_mode(tmp_path: Path) -> None: + """A config write must never silently change an existing file's mode.""" + path = tmp_path / "config.json" + path.write_text("{}", encoding="utf-8") + os.chmod(path, 0o640) + write_json_atomic(path, {"a": 1}) + assert stat.S_IMODE(path.stat().st_mode) == 0o640 + + +@posix_only +def test_write_new_file_uses_umask_default_not_mkstemp_0600(tmp_path: Path) -> None: + """mkstemp's 0600 must not leak into a new config.json. + + A root-created 0600 file would be unreadable to the service user after + ``octop service start --scope system``; the two pre-existing config.json + writers (``db/rebind.py``, ``backup/auto.py``) produce the umask default. + """ + mask = os.umask(0o022) + os.umask(mask) + path = tmp_path / "fresh.json" + write_json_atomic(path, {"a": 1}) + assert stat.S_IMODE(path.stat().st_mode) == 0o666 & ~mask diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c5e8cda3..cb24af34 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -408,3 +408,25 @@ def test_loads_mobile_capabilities(tmp_path: Path) -> None: cfg = load_config(cfg_path) assert cfg.capabilities.mobile.enabled is True assert cfg.capabilities.mobile.backend == "physical" + + +def test_corrupt_config_names_file_and_position_without_leaking_contents(tmp_path: Path): + """issue #730: a bare JSONDecodeError never says *which* config file failed.""" + cfg_path = tmp_path / "config.json" + original = '{"database": {"password": "s3cret"},}' # trailing comma + cfg_path.write_text(original, encoding="utf-8") + with pytest.raises(ValueError, match=r"line \d+, column \d+") as excinfo: + load_config(cfg_path) + msg = str(excinfo.value) + assert str(cfg_path) in msg + assert "s3cret" not in msg + # the file is left exactly as the user wrote it + assert cfg_path.read_text(encoding="utf-8") == original + + +def test_non_object_config_raises(tmp_path: Path): + cfg_path = tmp_path / "config.json" + cfg_path.write_text("[1, 2]", encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + load_config(cfg_path) + assert cfg_path.read_text(encoding="utf-8") == "[1, 2]" diff --git a/tests/unit/test_plugin_manager.py b/tests/unit/test_plugin_manager.py index 87bb2d2d..70a516de 100644 --- a/tests/unit/test_plugin_manager.py +++ b/tests/unit/test_plugin_manager.py @@ -289,3 +289,38 @@ def test_parse_plugin_icon(tmp_path: Path) -> None: ) assert parse_plugin_icon(plugin_dir) == "🧩" assert parse_plugin_icon(tmp_path / "missing") is None + + +def test_set_enabled_refuses_corrupt_config_and_preserves_bytes(tmp_path: Path) -> None: + """issue #730: the dashboard toggle must not wipe an unparseable config.json.""" + config_path = tmp_path / "config.json" + config_path.write_text("{}", encoding="utf-8") + mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path) + mgr.install_path(_FIXTURE, force=True) + + corrupt = '{"bind_host": "0.0.0.0", "database": {"driver": "postgresql"},}' + config_path.write_text(corrupt, encoding="utf-8") + with pytest.raises(OctopError) as excinfo: + mgr.set_enabled("echo-tool", False) + assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT + assert config_path.read_text(encoding="utf-8") == corrupt + + +def test_set_enabled_preserves_unrelated_keys(tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "bind_host": "0.0.0.0", + "database": {"driver": "postgresql", "host": "db.internal"}, + } + ), + encoding="utf-8", + ) + mgr = PluginManager(plugins_dir=tmp_path / "plugins", config_path=config_path) + mgr.install_path(_FIXTURE, force=True) + mgr.set_enabled("echo-tool", False) + data = json.loads(config_path.read_text(encoding="utf-8")) + assert data["bind_host"] == "0.0.0.0" + assert data["database"] == {"driver": "postgresql", "host": "db.internal"} + assert data["plugins"]["echo-tool"]["enabled"] is False diff --git a/tests/unit/test_plugin_seed.py b/tests/unit/test_plugin_seed.py index e88dbea4..08468dae 100644 --- a/tests/unit/test_plugin_seed.py +++ b/tests/unit/test_plugin_seed.py @@ -11,6 +11,7 @@ from octop.infra.agents.plugins.manager import PluginManager from octop.infra.agents.plugins.seed import seed_bundled_plugins +from octop.infra.errors import ErrorCode, OctopError def _write_plugin(root: Path, plugin_id: str, *, version: str = "0.1.0") -> Path: @@ -172,3 +173,20 @@ def test_load_installed_clears_stale_registry_entries(tmp_path: Path) -> None: assert [item.manifest.id for item in loaded] == ["weather"] assert PluginRegistry().get("stale") is None assert PluginRegistry().get("weather") is not None + + +def test_seed_refuses_corrupt_config_and_preserves_bytes(tmp_path: Path) -> None: + """issue #730: seeding always writes back, so a corrupt file must not read as ``{}``.""" + bundled = tmp_path / "bundled" + _write_plugin(bundled, "weather") + config_path = tmp_path / "config.json" + original = '{\n "bind_host": "0.0.0.0",\n "database": {"driver": "postgresql"}\n,}' + config_path.write_text(original, encoding="utf-8") + with pytest.raises(OctopError) as excinfo: + seed_bundled_plugins( + bundled_root=bundled, + plugins_dir=tmp_path / "plugins", + config_path=config_path, + ) + assert excinfo.value.code is ErrorCode.CONFIG_FILE_CORRUPT + assert config_path.read_text(encoding="utf-8") == original From 6acc1777d74c816bc0d3ee963022e741760bda4d Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Wed, 7 Oct 2026 23:06:57 +0800 Subject: [PATCH 04/39] chore(release): prepare FreeOS 0.1.0 --- .github/workflows/release.yml | 34 +- CHANGELOG.md | 16 + CONTRIBUTING.md | 2 +- Makefile | 2 +- dashboard/package-lock.json | 1481 ++++------------- dashboard/package.json | 15 +- .../pages/employees/EmployeesPage.test.tsx | 3 + .../pages/workspace/WorkspacePage.test.tsx | 2 +- .../src/pages/Admin/Users/SsoPanel.test.tsx | 10 +- .../Agent/Memory/ProfileOverview.test.tsx | 2 +- .../Chat/components/ChatDockReviewContent.tsx | 4 +- .../Chat/components/ChatDockStartState.tsx | 8 +- .../Chat/components/ChatDockTasksContent.tsx | 4 +- .../pages/Chat/components/ChatPanelPicker.tsx | 5 +- .../components/TrajectoryInspector.test.tsx | 21 +- .../src/pages/Chat/hooks/useChatDockPanel.ts | 15 +- .../src/pages/Chat/utils/chatMessages.test.ts | 14 +- .../src/pages/Chat/utils/expertMention.ts | 4 +- .../components/modals/CustomProviderModal.tsx | 4 +- dashboard/src/test/setup.ts | 2 + docs/cli.md | 9 +- docs/node-runtime.md | 10 +- docs/node-runtime.zh-CN.md | 8 +- docs/user-guide.md | 4 +- pyproject.toml | 2 +- scripts/README.md | 15 +- scripts/install-octop.sh | 16 +- scripts/install.bat | 14 +- scripts/install.ps1 | 11 +- scripts/install.sh | 16 +- src/octop/__init__.py | 2 +- src/octop/cli/registry.py | 4 +- src/octop/infra/agents/manager.py | 4 + .../middleware/system_message_cleanup.py | 34 + .../unit/agents/test_octop_builtin_skills.py | 8 +- .../agents/test_system_message_cleanup.py | 62 + tests/unit/cli/test_completion_cmd.py | 4 +- tests/unit/db/test_clip_thread_title.py | 2 +- tests/unit/db/test_db_pool.py | 20 +- tests/unit/db/test_published_experts_repo.py | 6 +- tests/unit/db/test_repo_knowledge.py | 2 +- tests/unit/db/test_skill_package_icons.py | 10 +- tests/unit/db/test_skill_packages_repo.py | 6 +- uv.lock | 2 +- 44 files changed, 643 insertions(+), 1276 deletions(-) create mode 100644 src/octop/infra/agents/middleware/system_message_cleanup.py create mode 100644 tests/unit/agents/test_system_message_cleanup.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf626dad..199093df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Release -# 版本标签 v* → 测包、发 PyPI、建 GitHub Release,再级联: +# 版本标签 v* → 测包、构建 GitHub Release 制品,再级联: # - Sync Main Into Develop # - Build Octop FPK(复用本 Release 的 wheel;等待 docker-publish 的 GHCR 镜像) # @@ -18,7 +18,6 @@ on: permissions: contents: write actions: write - id-token: write concurrency: group: release-${{ github.ref }} @@ -43,6 +42,11 @@ jobs: echo "Refusing release: tag $GITHUB_REF_NAME does not match pyproject.toml $package_version" >&2 exit 1 fi + git fetch origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Refusing release: production tags must point to a commit on main" >&2 + exit 1 + fi - uses: astral-sh/setup-uv@v4 with: @@ -73,26 +77,9 @@ jobs: name: dist path: dist/ - publish: - name: Publish to PyPI - needs: build - runs-on: ubuntu-latest - environment: pypi - steps: - - name: Download distributions - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ - - - name: Publish package - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - github-release: name: Create GitHub Release - needs: publish + needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -128,8 +115,9 @@ jobs: echo "" echo "## Install" echo "" + echo "Download the Python wheel from this GitHub Release and install it with:" echo '```bash' - echo "pip install octop==$version" + echo "uv pip install ./octop-$version-py3-none-any.whl" echo '```' } > .release-notes.md fi @@ -165,7 +153,7 @@ jobs: -f version="${{ github.ref_name }}" # FnOS FPK:复用本 Release 的 wheel + docker-publish 的 GHCR 镜像。 - # 与 sync-develop 并行。Dispatch 失败必须让本 workflow 变红(PyPI 已发布仍如此)。 + # 与 sync-develop 并行。Dispatch 失败必须让本 workflow 变红,以便发现下游构建漏触发。 trigger-fnos: name: Trigger FnOS FPK build needs: github-release @@ -175,7 +163,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # --ref 用版本 tag,保证 FPK 打在与 PyPI / 镜像相同的 commit 上 + # --ref 用版本 tag,保证 FPK 打在与 GitHub Release / 镜像相同的 commit 上 gh workflow run fnos-build-fpk.yml \ --repo "${{ github.repository }}" \ --ref "${{ github.ref_name }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e96071..15367e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,27 @@ ## [Unreleased] +### Changed + +- Publish Python distributions as GitHub Release assets only; the FreeOS release workflow no longer publishes the colliding `octop` package name to PyPI, and install scripts default to the FreeOS GitHub source. + ### Fixed +- Remove checkpoint-resident system messages before model calls while preserving the active compiled prompt, preventing strict Qwen chat templates from failing when an older conversation contains a stale bootstrap message. - Detect models from an already-running Ollama daemon even when FreeOS-managed Ollama startup is disabled; model discovery never starts Ollama unless the user enabled it. - Preserve `config.json` when it is malformed: startup and all configuration writers now report the file and parser position, avoid leaking stored credentials, and use atomic replacement instead of silently rebuilding an empty configuration. +## [0.1.0] - 2026-10-07 + +### Changed + +- Publish the successor to 0.0.10 as 0.1.0, with Python distributions attached to GitHub Releases only. + +### Fixed + +- Remove stale checkpoint system messages before model calls, detect models from an already-running Ollama daemon without starting it, and preserve malformed `config.json` files instead of silently replacing them. +- Improve desktop update safety by checkpointing SQLite WAL before restart. + ## [0.0.10] - 2026-10-03 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d1cd7ad..cba35a82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,7 +73,7 @@ See [AGENTS.md](AGENTS.md) for module boundaries and coding conventions. 1. Cut `release/x.y.z` from latest `develop` (version bump + CHANGELOG on that branch) 2. Open PR: `release/x.y.z` → `main` and merge when green -3. Tag `v` on **main tip** and push — GitHub Actions builds, publishes to PyPI, and creates the GitHub Release +3. Tag `v` on **main tip** and push — GitHub Actions builds Python distributions and creates a GitHub Release with the artifacts; FreeOS does not publish the `octop` distribution to PyPI 4. Delete `release/x.y.z`; Actions syncs `main` → `develop` (or opens `chore/sync-develop-after-*` if merge conflicts / branch protection) Agent-assisted publish: `.cursor/skills/publish` (`/publish `). diff --git a/Makefile b/Makefile index cdf862e8..3b73053c 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ RUN := $(if $(UV),uv run,) PYTHON := $(if $(UV),uv run python,python3) PIP := $(if $(UV),uv pip,python3 -m pip) # Parallel workers for make test / make test-fast (override: make test PYTEST_JOBS=4) -PYTEST_JOBS ?= auto +PYTEST_JOBS ?= 4 # ─── Help ──────────────────────────────────────────────────────────────────── diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index ff1fc723..9ef4a83a 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -17,12 +17,11 @@ "ahooks": "^3.9.6", "antd": "^5.29.1", "antd-style": "^3.7.1", - "build": "^0.1.4", "docx-preview": "^0.3.2", "i18next": "^25.8.4", "jszip": "^3.10.1", "lucide-react": "^0.562.0", - "mermaid": "^11.13.0", + "mermaid": "^12.1.0", "qrcode.react": "^4.2.0", "react": "^18", "react-dom": "^18", @@ -30,14 +29,14 @@ "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", "react-pdf": "^10.5.0", - "react-router-dom": "^7.13.0", + "react-router-dom": "^7.18.4", "react-syntax-highlighter": "^16.1.1", "react-virtuoso": "^4.18.10", "recharts": "^3.10.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "xlsx": "^0.18.5" + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -51,7 +50,7 @@ "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.4.1", - "@vitest/coverage-v8": "^3.2.6", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -65,7 +64,7 @@ "typescript-eslint": "^8.30.1", "vite": "^6.3.5", "vite-plugin-pwa": "^0.21.2", - "vitest": "^3.2.6" + "vitest": "^4.1.11" } }, "node_modules/@adobe/css-tools": { @@ -93,20 +92,6 @@ } } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@ant-design/colors": { "version": "7.2.1", "resolved": "https://mirrors.tencent.com/npm/@ant-design/colors/-/colors-7.2.1.tgz", @@ -1807,20 +1792,54 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/gast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/gast/node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", + "license": "Apache-2.0" + }, "node_modules/@chevrotain/types": { "version": "11.1.2", "resolved": "https://mirrors.tencent.com/npm/@chevrotain/types/-/types-11.1.2.tgz", "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://mirrors.tencent.com/npm/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } + "node_modules/@chevrotain/utils": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", + "license": "Apache-2.0" }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", @@ -1939,17 +1958,6 @@ "node": ">=18" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://mirrors.tencent.com/npm/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://mirrors.tencent.com/npm/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -2798,16 +2806,6 @@ "node": ">=18" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://mirrors.tencent.com/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3146,17 +3144,6 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@rc-component/async-validator": { "version": "5.1.0", "resolved": "https://mirrors.tencent.com/npm/@rc-component/async-validator/-/async-validator-5.1.0.tgz", @@ -3805,16 +3792,6 @@ "win32" ] }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://mirrors.tencent.com/npm/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", - "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://mirrors.tencent.com/npm/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4409,12 +4386,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://mirrors.tencent.com/npm/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://mirrors.tencent.com/npm/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4631,15 +4602,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -4761,32 +4733,30 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", - "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.6", - "vitest": "3.2.6" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4795,39 +4765,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -4849,42 +4820,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -4892,28 +4863,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4967,15 +4935,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -5207,9 +5166,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -5238,7 +5197,8 @@ "node_modules/async": { "version": "3.2.6", "resolved": "https://mirrors.tencent.com/npm/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true }, "node_modules/async-function": { "version": "1.0.0", @@ -5355,9 +5315,9 @@ "dev": true }, "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://mirrors.tencent.com/npm/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5368,19 +5328,20 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://mirrors.tencent.com/npm/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -5396,13 +5357,14 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -5417,36 +5379,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/build": { - "version": "0.1.4", - "resolved": "https://mirrors.tencent.com/npm/build/-/build-0.1.4.tgz", - "integrity": "sha512-KwbDJ/zrsU8KZRRMfoURG14cKIAStUlS8D5jBDvtrZbwO5FEkYqc3oB8HIhRiyD64A48w1lc+sOmQ+mmBw5U/Q==", - "dependencies": { - "cssmin": "0.3.x", - "jsmin": "1.x", - "jxLoader": "*", - "moo-server": "*", - "promised-io": "*", - "timespan": "2.x", - "uglify-js": "1.x", - "walker": "1.x", - "winston": "*", - "wrench": "1.3.x" - }, - "engines": { - "node": ">v0.4.12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://mirrors.tencent.com/npm/call-bind/-/call-bind-1.0.9.tgz", @@ -5505,9 +5437,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://mirrors.tencent.com/npm/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -5535,32 +5467,12 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -5618,16 +5530,28 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" + }, "engines": { - "node": ">= 16" + "node": ">=22.0.0" } }, + "node_modules/chevrotain/node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://mirrors.tencent.com/npm/classnames/-/classnames-2.5.1.tgz", @@ -5657,27 +5581,6 @@ "node": ">=6" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://mirrors.tencent.com/npm/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/color-convert/-/color-convert-2.0.1.tgz", @@ -5696,44 +5599,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://mirrors.tencent.com/npm/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-string/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://mirrors.tencent.com/npm/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://mirrors.tencent.com/npm/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://mirrors.tencent.com/npm/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "engines": { - "node": ">=12.20" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5796,7 +5661,7 @@ }, "node_modules/cookie": { "version": "1.1.1", - "resolved": "https://mirrors.tencent.com/npm/cookie/-/cookie-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { @@ -5877,18 +5742,6 @@ "node": ">= 6" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://mirrors.tencent.com/npm/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5920,14 +5773,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssmin": { - "version": "0.3.2", - "resolved": "https://mirrors.tencent.com/npm/cssmin/-/cssmin-0.3.2.tgz", - "integrity": "sha512-bynxGIAJ8ybrnFobjsQotIjA8HFDDgPwbeUWNXXXfR+B4f9kkxdcUyagJoQCSUOfMV+ZZ6bMn8bvbozlCzUGwQ==", - "bin": { - "cssmin": "bin/cssmin" - } - }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -5956,9 +5801,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.4", - "resolved": "https://mirrors.tencent.com/npm/cytoscape/-/cytoscape-3.33.4.tgz", - "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", "license": "MIT", "peer": true, "engines": { @@ -6604,16 +6449,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://mirrors.tencent.com/npm/deep-is/-/deep-is-0.1.4.tgz", @@ -6733,9 +6568,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.7", - "resolved": "https://mirrors.tencent.com/npm/dompurify/-/dompurify-3.4.7.tgz", - "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6756,13 +6591,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/echarts": { "version": "6.1.0", "resolved": "https://mirrors.tencent.com/npm/echarts/-/echarts-6.1.0.tgz", @@ -6795,10 +6623,17 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://mirrors.tencent.com/npm/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", - "dev": true + "version": "1.5.445", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.445.tgz", + "integrity": "sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -6806,12 +6641,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://mirrors.tencent.com/npm/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://mirrors.tencent.com/npm/entities/-/entities-6.0.1.tgz", @@ -6935,9 +6764,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -7300,9 +7129,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://mirrors.tencent.com/npm/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -7313,7 +7142,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fault": { "version": "1.0.4", @@ -7346,12 +7176,6 @@ } } }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://mirrors.tencent.com/npm/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://mirrors.tencent.com/npm/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7375,10 +7199,11 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -7436,12 +7261,6 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://mirrors.tencent.com/npm/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://mirrors.tencent.com/npm/for-each/-/for-each-0.3.5.tgz", @@ -7500,15 +7319,6 @@ "node": ">=0.4.x" } }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://mirrors.tencent.com/npm/fs-extra/-/fs-extra-9.1.0.tgz", @@ -7717,15 +7527,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -8730,6 +8541,7 @@ "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8924,21 +8736,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -8999,9 +8796,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -9110,18 +8907,6 @@ "node": ">=6" } }, - "node_modules/jsmin": { - "version": "1.0.1", - "resolved": "https://mirrors.tencent.com/npm/jsmin/-/jsmin-1.0.1.tgz", - "integrity": "sha512-OPuL5X/bFKgVdMvEIX3hnpx3jbVpFCrEM8pKPXjFkZUqg521r41ijdyTz7vACOhW6o1neVlcLyd+wkbK5fNHRg==", - "license": "Doug Crockford's license that allows this module to be used for Good but not for Evil", - "bin": { - "jsmin": "bin/jsmin" - }, - "engines": { - "node": ">=0.1.93" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://mirrors.tencent.com/npm/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9201,51 +8986,29 @@ "setimmediate": "^1.0.5" } }, - "node_modules/jxLoader": { - "version": "0.1.1", - "resolved": "https://mirrors.tencent.com/npm/jxLoader/-/jxLoader-0.1.1.tgz", - "integrity": "sha512-ClEvAj3K68y8uKhub3RgTmcRPo5DfIWvtxqrKQdDPyZ1UVHIIKvVvjrAsJFSVL5wjv0rt5iH9SMCZ0XRKNzeUA==", - "dependencies": { - "js-yaml": "0.3.x", - "moo-server": "1.3.x", - "promised-io": "*", - "walker": "1.x" - }, - "engines": { - "node": ">v0.4.10" - } - }, - "node_modules/jxLoader/node_modules/js-yaml": { - "version": "0.3.7", - "resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-0.3.7.tgz", - "integrity": "sha512-/7PsVDNP2tVe2Z1cF9kTEkjamIwz4aooDpRKmN1+g/9eePCgcxsv4QDvEbxO0EH+gdDD7MLyDoR6BASo3hH51g==", - "engines": { - "node": "> 0.4.11" - } - }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://mirrors.tencent.com/npm/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.0.tgz", + "integrity": "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], "license": "MIT", "dependencies": { - "commander": "^8.3.0" + "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://mirrors.tencent.com/npm/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=22.12.0" } }, "node_modules/keyv": { @@ -9262,12 +9025,6 @@ "resolved": "https://mirrors.tencent.com/npm/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://mirrors.tencent.com/npm/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://mirrors.tencent.com/npm/layout-base/-/layout-base-1.0.2.tgz", @@ -9393,23 +9150,6 @@ "dev": true, "license": "MIT" }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://mirrors.tencent.com/npm/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://mirrors.tencent.com/npm/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9431,13 +9171,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://mirrors.tencent.com/npm/lowlight/-/lowlight-1.20.0.tgz", @@ -9491,15 +9224,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" } }, "node_modules/make-cancellable-promise": { @@ -9545,14 +9278,6 @@ "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" } }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://mirrors.tencent.com/npm/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://mirrors.tencent.com/npm/markdown-table/-/markdown-table-3.0.4.tgz", @@ -9902,39 +9627,63 @@ } }, "node_modules/mermaid": { - "version": "11.15.0", - "resolved": "https://mirrors.tencent.com/npm/mermaid/-/mermaid-11.15.0.tgz", - "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.1.0.tgz", + "integrity": "sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==", + "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.1", + "@mermaid-js/parser": "^2.0.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "chevrotain": "~13.2.0", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dayjs": "^1.11.21", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.25", + "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" } }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://mirrors.tencent.com/npm/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { + "node_modules/mermaid/node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/mermaid/node_modules/@mermaid-js/parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~13.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://mirrors.tencent.com/npm/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { "type": "GitHub Sponsors", "url": "https://github.com/sponsors/unifiedjs" }, @@ -10579,24 +10328,15 @@ } }, "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://mirrors.tencent.com/npm/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", + "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", "license": "MIT", "dependencies": { - "dompurify": "3.2.7", + "dompurify": "3.4.15", "marked": "14.0.0" } }, - "node_modules/monaco-editor/node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://mirrors.tencent.com/npm/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/monaco-editor/node_modules/marked": { "version": "14.0.0", "resolved": "https://mirrors.tencent.com/npm/marked/-/marked-14.0.0.tgz", @@ -10608,23 +10348,15 @@ "node": ">= 18" } }, - "node_modules/moo-server": { - "version": "1.3.0", - "resolved": "https://mirrors.tencent.com/npm/moo-server/-/moo-server-1.3.0.tgz", - "integrity": "sha512-9A8/eor2DXwpv1+a4pZAAydqLFVrWoKoO1fzdzqLUhYVXAO1Kgd1FR2gFZi7YdHzF0s4W8cDNwCfKJQrvLqxDw==", - "engines": { - "node": ">v0.4.10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://mirrors.tencent.com/npm/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -10665,10 +10397,11 @@ } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://mirrors.tencent.com/npm/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -10721,13 +10454,18 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://mirrors.tencent.com/npm/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" + "engines": { + "node": ">=12.20.0" } }, "node_modules/open": { @@ -10978,16 +10716,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pdfjs-dist": { "version": "5.4.296", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", @@ -11067,9 +10795,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://mirrors.tencent.com/npm/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -11085,10 +10813,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11181,11 +10910,6 @@ "resolved": "https://mirrors.tencent.com/npm/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, - "node_modules/promised-io": { - "version": "0.3.6", - "resolved": "https://mirrors.tencent.com/npm/promised-io/-/promised-io-0.3.6.tgz", - "integrity": "sha512-bNwZusuNIW4m0SPR8jooSyndD35ggirHlxVl/UhIaZD/F0OBv9ebfc6tNmbpZts3QXHggkjIBH8lvtnzhtcz0A==" - }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://mirrors.tencent.com/npm/property-information/-/property-information-7.2.0.tgz", @@ -12011,9 +11735,9 @@ } }, "node_modules/react-router": { - "version": "7.16.0", - "resolved": "https://mirrors.tencent.com/npm/react-router/-/react-router-7.16.0.tgz", - "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -12033,12 +11757,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.16.0", - "resolved": "https://mirrors.tencent.com/npm/react-router-dom/-/react-router-dom-7.16.0.tgz", - "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", "license": "MIT", "dependencies": { - "react-router": "7.16.0" + "react-router": "7.18.4" }, "engines": { "node": ">=20.0.0" @@ -12620,15 +12344,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://mirrors.tencent.com/npm/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://mirrors.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -12702,7 +12417,7 @@ }, "node_modules/set-cookie-parser": { "version": "2.7.2", - "resolved": "https://mirrors.tencent.com/npm/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, @@ -12896,10 +12611,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://mirrors.tencent.com/npm/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12935,27 +12651,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://mirrors.tencent.com/npm/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -12970,9 +12665,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -13020,22 +12715,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://mirrors.tencent.com/npm/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -13162,20 +12841,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://mirrors.tencent.com/npm/strip-comments/-/strip-comments-2.0.1.tgz", @@ -13211,26 +12876,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://mirrors.tencent.com/npm/style-to-js/-/style-to-js-1.1.21.tgz", @@ -13341,264 +12986,6 @@ "dev": true, "license": "MIT" }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/test-exclude/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/test-exclude/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/test-exclude/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/test-exclude/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/test-exclude/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://mirrors.tencent.com/npm/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://mirrors.tencent.com/npm/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -13608,14 +12995,6 @@ "node": ">=12.22" } }, - "node_modules/timespan": { - "version": "2.3.0", - "resolved": "https://mirrors.tencent.com/npm/timespan/-/timespan-2.3.0.tgz", - "integrity": "sha512-0Jq9+58T2wbOyLth0EU+AUb6JMGCLaTWIykJFa7hyAybjVH9gpVMTfUAwo5fWAvtFt2Tjh/Elg8JtgNpnMnM8g==", - "engines": { - "node": ">= 0.2.0" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://mirrors.tencent.com/npm/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -13655,30 +13034,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -13705,11 +13064,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://mirrors.tencent.com/npm/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" - }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", @@ -13748,14 +13102,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://mirrors.tencent.com/npm/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://mirrors.tencent.com/npm/trough/-/trough-2.2.0.tgz", @@ -13932,14 +13278,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/uglify-js": { - "version": "1.3.5", - "resolved": "https://mirrors.tencent.com/npm/uglify-js/-/uglify-js-1.3.5.tgz", - "integrity": "sha512-YPX1DjKtom8l9XslmPFQnqWzTBkvI4N0pbkzLuPZZ4QTyig0uQqvZz9NgUdfEV+qccJzi7fVcGWdESvRIjWptQ==", - "bin": { - "uglifyjs": "bin/uglifyjs" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://mirrors.tencent.com/npm/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -14157,9 +13495,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://mirrors.tencent.com/npm/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -14373,29 +13711,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-plugin-pwa": { "version": "0.21.2", "resolved": "https://mirrors.tencent.com/npm/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", @@ -14428,66 +13743,80 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -14498,16 +13827,12 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://mirrors.tencent.com/npm/void-elements/-/void-elements-3.1.0.tgz", @@ -14530,14 +13855,6 @@ "node": ">=18" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://mirrors.tencent.com/npm/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -14727,85 +14044,6 @@ "node": ">=8" } }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://mirrors.tencent.com/npm/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://mirrors.tencent.com/npm/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/winston/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://mirrors.tencent.com/npm/word-wrap/-/word-wrap-1.2.5.tgz", @@ -15107,34 +14345,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrench": { - "version": "1.3.9", - "resolved": "https://mirrors.tencent.com/npm/wrench/-/wrench-1.3.9.tgz", - "integrity": "sha512-srTJQmLTP5YtW+F5zDuqjMEZqLLr/eJOZfDI5ibfPfRMeDh3oBUefAscuH0q5wBKE339ptH/S/0D18ZkfOfmKQ==", - "deprecated": "wrench.js is deprecated! You should check out fs-extra (https://github.com/jprichardson/node-fs-extra) for any operations you were using wrench for. Thanks for all the usage over the years.", - "engines": { - "node": ">=0.1.97" - } - }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -15158,19 +14368,10 @@ } }, "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, "bin": { "xlsx": "bin/xlsx.njs" }, diff --git a/dashboard/package.json b/dashboard/package.json index 4d62cdec..e0e6d198 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -29,12 +29,11 @@ "ahooks": "^3.9.6", "antd": "^5.29.1", "antd-style": "^3.7.1", - "build": "^0.1.4", "docx-preview": "^0.3.2", "i18next": "^25.8.4", "jszip": "^3.10.1", "lucide-react": "^0.562.0", - "mermaid": "^11.13.0", + "mermaid": "^12.1.0", "qrcode.react": "^4.2.0", "react": "^18", "react-dom": "^18", @@ -42,18 +41,20 @@ "react-json-view-lite": "^2.5.0", "react-markdown": "^10.1.0", "react-pdf": "^10.5.0", - "react-router-dom": "^7.13.0", + "react-router-dom": "^7.18.4", "react-syntax-highlighter": "^16.1.1", "react-virtuoso": "^4.18.10", "recharts": "^3.10.1", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "xlsx": "^0.18.5" + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "overrides": { "serialize-javascript": "^7.0.3", - "@rollup/plugin-terser": "^1.0.0" + "@rollup/plugin-terser": "^1.0.0", + "katex": "^0.19.0", + "dompurify": "^3.4.16" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -67,7 +68,7 @@ "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.4.1", - "@vitest/coverage-v8": "^3.2.6", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -81,6 +82,6 @@ "typescript-eslint": "^8.30.1", "vite": "^6.3.5", "vite-plugin-pwa": "^0.21.2", - "vitest": "^3.2.6" + "vitest": "^4.1.11" } } diff --git a/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx b/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx index ce5f2998..5c3db84c 100644 --- a/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx +++ b/dashboard/src/org-ui/pages/employees/EmployeesPage.test.tsx @@ -68,6 +68,9 @@ describe("EmployeesPage", () => { onOpenEmployee={onOpenEmployee} />, ); + await waitFor(() => expect(client.listDepartments).toHaveBeenCalledOnce()); + await client.listDepartments.mock.results[0]?.value; + await screen.findByTestId("org-employees-empty"); fireEvent.click(await screen.findByTestId("org-employees-create")); fireEvent.change(screen.getByTestId("org-employees-name"), { target: { value: "Ada" }, diff --git a/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx b/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx index 7ea315e3..50302c56 100644 --- a/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx +++ b/dashboard/src/org-ui/pages/workspace/WorkspacePage.test.tsx @@ -118,7 +118,7 @@ describe("WorkspacePage", () => { it("hides governance and settings from non-admins", async () => { const client = mockClient(); render(); - await screen.findByTestId("org-ui-workspace"); + await screen.findByTestId("org-workspace-module-employees"); expect(screen.queryByTestId("org-workspace-module-governance")).toBeNull(); expect(screen.queryByTestId("org-workspace-module-settings")).toBeNull(); expect( diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx index d563d94c..df92b9f1 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx @@ -64,9 +64,15 @@ describe("", () => { render(); await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); - await user.click( - screen.getByRole("button", { name: "adminSso.presetGoogle" }), + const googlePreset = screen.getByRole("button", { + name: "adminSso.presetGoogle", + }); + await waitFor(() => + expect(window.getComputedStyle(googlePreset).pointerEvents).not.toBe( + "none", + ), ); + await user.click(googlePreset); expect(screen.getByDisplayValue("Google")).toBeInTheDocument(); }); }); diff --git a/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx b/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx index e5604b82..7f85a0d6 100644 --- a/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx +++ b/dashboard/src/pages/Agent/Memory/ProfileOverview.test.tsx @@ -108,7 +108,7 @@ describe("", () => { await waitFor(() => { expect(screen.getAllByText(/用户画像/).length).toBeGreaterThan(0); }); - expect(screen.getByText(/Octop 还在了解你/)).toBeInTheDocument(); + expect(await screen.findByText(/Octop 还在了解你/)).toBeInTheDocument(); }); it("caps a section at 6 rows and shows 查看全部 that calls onViewAll", async () => { diff --git a/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx b/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx index 5e9adcf2..5f23d75b 100644 --- a/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx @@ -63,9 +63,7 @@ export default function ChatDockReviewContent({ -
- {t("chat.rightRail.reviewEmpty", "还没有可审查的操作")} -
+
{t("chat.rightRail.reviewEmpty", "还没有可审查的操作")}

{t( "chat.rightRail.reviewEmptyHint", diff --git a/dashboard/src/pages/Chat/components/ChatDockStartState.tsx b/dashboard/src/pages/Chat/components/ChatDockStartState.tsx index 3702c964..1ccd9680 100644 --- a/dashboard/src/pages/Chat/components/ChatDockStartState.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockStartState.tsx @@ -23,7 +23,9 @@ interface ChatDockStartStateProps { onOpen: (kind: WorkspacePanelKind) => void; } -export default function ChatDockStartState({ onOpen }: ChatDockStartStateProps) { +export default function ChatDockStartState({ + onOpen, +}: ChatDockStartStateProps) { const { t } = useTranslation(); const title = (kind: WorkspacePanelKind) => { @@ -89,7 +91,9 @@ export default function ChatDockStartState({ onOpen }: ChatDockStartStateProps) {title(kind)} - {hint(kind)} + + {hint(kind)} + -

- {t("chat.rightRail.tasksEmpty", "当前没有后台任务")} -
+
{t("chat.rightRail.tasksEmpty", "当前没有后台任务")}

{t( "chat.rightRail.tasksEmptyHint", diff --git a/dashboard/src/pages/Chat/components/ChatPanelPicker.tsx b/dashboard/src/pages/Chat/components/ChatPanelPicker.tsx index d8d2f02b..3045d46d 100644 --- a/dashboard/src/pages/Chat/components/ChatPanelPicker.tsx +++ b/dashboard/src/pages/Chat/components/ChatPanelPicker.tsx @@ -10,10 +10,7 @@ import type { WorkspacePanelKind } from "../utils/workspacePanels"; import { WORKSPACE_PANEL_KINDS } from "../utils/workspacePanels"; import styles from "../index.module.less"; -const ICONS: Record< - WorkspacePanelKind, - typeof FileText -> = { +const ICONS: Record = { files: FileText, review: ScanSearch, tasks: ListChecks, diff --git a/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx b/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx index f35af7d4..134f5963 100644 --- a/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx +++ b/dashboard/src/pages/Chat/components/TrajectoryInspector.test.tsx @@ -181,17 +181,16 @@ describe("TrajectoryInspector", () => { fireEvent.click(screen.getByRole("tab", { name: "Preview" })); - await waitFor(() => { - expect(eventMock).toHaveBeenCalledWith("A1", "T1", "ctx-1"); - const preview = screen.getByTestId("trajectory-preview"); - expect(preview).toHaveTextContent("Prefer BackendWorkspace paths"); - // Rendered markdown — not the raw ## / ** markers. - expect(preview.querySelector("h2")).toHaveTextContent("Rules"); - expect(preview.querySelector("strong")).toHaveTextContent( - "BackendWorkspace", - ); - expect(preview).not.toHaveTextContent("summary: meta"); - }); + await screen.findByRole("heading", { name: "Rules" }, { timeout: 5000 }); + expect(eventMock).toHaveBeenCalledWith("A1", "T1", "ctx-1"); + const preview = screen.getByTestId("trajectory-preview"); + expect(preview).toHaveTextContent("Prefer BackendWorkspace paths"); + // Rendered markdown — not the raw ## / ** markers. + expect(preview.querySelector("h2")).toHaveTextContent("Rules"); + expect(preview.querySelector("strong")).toHaveTextContent( + "BackendWorkspace", + ); + expect(preview).not.toHaveTextContent("summary: meta"); }); it("labels the middle tab Result for tool events", () => { diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index d28cb929..03caf059 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -327,7 +327,12 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { /** Toggle dock open/closed around a dedicated tab (browser / terminal). */ const toggleDockTab = useCallback( - (tab: Extract) => { + ( + tab: Extract< + DockTab, + { kind: "browser" | "terminal" | "review" | "tasks" } + >, + ) => { setDockOpen((prevOpen) => { if (prevOpen && activeTabId === tab.id) { persistRailOpen(false); @@ -376,7 +381,13 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { } openTerminalTab(); }, - [openBrowserTab, openFileList, openReviewTab, openTasksTab, openTerminalTab], + [ + openBrowserTab, + openFileList, + openReviewTab, + openTasksTab, + openTerminalTab, + ], ); const closeTab = useCallback((id: DockTabId) => { diff --git a/dashboard/src/pages/Chat/utils/chatMessages.test.ts b/dashboard/src/pages/Chat/utils/chatMessages.test.ts index 3b0f3c94..aee173e5 100644 --- a/dashboard/src/pages/Chat/utils/chatMessages.test.ts +++ b/dashboard/src/pages/Chat/utils/chatMessages.test.ts @@ -31,13 +31,15 @@ describe("formatRunUsage", () => { describe("composer permissionMode", () => { it("stamps and normalizes permissionMode", () => { + expect(buildComposerContext({ permissionMode: "auto" })).toEqual({ + permissionMode: "auto", + }); + expect(normalizeComposerContext({ permissionMode: "full" })).toEqual({ + permissionMode: "full", + }); expect( - buildComposerContext({ permissionMode: "auto" }), - ).toEqual({ permissionMode: "auto" }); - expect( - normalizeComposerContext({ permissionMode: "full" }), - ).toEqual({ permissionMode: "full" }); - expect(normalizeComposerContext({ permissionMode: "yolo" })).toBeUndefined(); + normalizeComposerContext({ permissionMode: "yolo" }), + ).toBeUndefined(); }); }); diff --git a/dashboard/src/pages/Chat/utils/expertMention.ts b/dashboard/src/pages/Chat/utils/expertMention.ts index c6764291..856d2c84 100644 --- a/dashboard/src/pages/Chat/utils/expertMention.ts +++ b/dashboard/src/pages/Chat/utils/expertMention.ts @@ -30,7 +30,9 @@ export function textHasExpertMention(text: string, name: string): boolean { export const EVERYONE_MENTION_NAMES = ["所有人", "everyone"] as const; export function textHasEveryoneMention(text: string): boolean { - return EVERYONE_MENTION_NAMES.some((name) => textHasExpertMention(text, name)); + return EVERYONE_MENTION_NAMES.some((name) => + textHasExpertMention(text, name), + ); } export function mentionedExpertIds( diff --git a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx index b0673971..ef0c49bf 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx @@ -263,8 +263,8 @@ export function CustomProviderModal({ k.value === "openai" ? t("models.kindOpenaiCompat") : k.value === "anthropic" - ? "Anthropic" - : "AWS Bedrock", + ? "Anthropic" + : "AWS Bedrock", }))} /> diff --git a/dashboard/src/test/setup.ts b/dashboard/src/test/setup.ts index 72ae7d13..f2b1ad82 100644 --- a/dashboard/src/test/setup.ts +++ b/dashboard/src/test/setup.ts @@ -14,6 +14,7 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; +import { message } from "@/utils/antdMessage"; // Auto-mock react-i18next so components' ``t(key, fallback)`` calls // resolve synchronously to ``fallback`` without needing the real @@ -56,6 +57,7 @@ vi.mock("react-i18next", async (importOriginal) => { afterEach(() => { cleanup(); + message.destroy(); }); if (typeof window !== "undefined") { diff --git a/docs/cli.md b/docs/cli.md index b2a78e48..ae5249eb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI Reference -`octop` is a single Click app installed as `octop` on `pip install octop`. +`octop` and `freeos` are compatibility command names for the FreeOS CLI. The supported installer obtains FreeOS from the XYAIStudio GitHub repository; Python release artifacts are attached to GitHub Releases. It is registered in `src/octop/cli/main.py`; commands are loaded lazily from `src/octop/cli/registry.py`. @@ -363,8 +363,7 @@ retention only deletes those files and never touches manual ## `octop update` -Check for and install a newer Octop release from the configured -channel (PyPI by default). +Check for and install a newer FreeOS desktop release from GitHub Releases. ``` Usage: octop update [OPTIONS] @@ -373,7 +372,9 @@ Usage: octop update [OPTIONS] Options: --check / --no-check Only check; do not install. - --channel TEXT Release channel (pypi, testpypi, custom index). + --allow-prerelease Include alpha / beta / rc / dev releases. + --yes, -y Skip upgrade confirmation. + --verbose, -v Show installer output. -h, --help Show this message and exit. ``` diff --git a/docs/node-runtime.md b/docs/node-runtime.md index 78aaa7c2..c9f6cd51 100644 --- a/docs/node-runtime.md +++ b/docs/node-runtime.md @@ -65,15 +65,15 @@ expanding `SHIP_OPENXYOS_RUNTIME=1` to new flavors. | GHCR image | `ghcr.io/xyaistudio/freeos:{version\|latest}` | | Local Compose tag | `octop:latest` (compatibility alias; one Python process) | | Security advisories | [XYAIStudio/FreeOS](https://github.com/XYAIStudio/FreeOS/security/advisories/new) | -| PyPI package name | `octop` (intentional remaining identifier; CLI is `freeos`) | +| Python distribution identifier | `octop` (internal compatibility name; distributed through GitHub Releases only) | -### Release / PyPI / Docker gates +### Release / Docker gates - **Docker Publish** pushes GHCR always. Docker Hub login is **optional** (`DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`). Missing Hub secrets must not fail the workflow. -- **PyPI** still publishes the `octop` distribution (`PYPI_API_TOKEN` on the - `pypi` environment). That name is the Python package, not a pointer at - Tencent Cloud advisories. +- **Python distributions** are attached to GitHub Releases. Do not publish the + `octop` distribution to PyPI: that name is already used by the upstream + Octop project and would create an installation identity collision. - **FnOS Docker `.fpk`** compose must pull `ghcr.io/xyaistudio/freeos`, not `ghcr.io/tencentcloud/octop`. diff --git a/docs/node-runtime.zh-CN.md b/docs/node-runtime.zh-CN.md index 73647c4a..b24736fe 100644 --- a/docs/node-runtime.zh-CN.md +++ b/docs/node-runtime.zh-CN.md @@ -60,13 +60,13 @@ GitHub 工作流 **FreeOS Desktop Package**(`.github/workflows/octop-desktop.y | GHCR 镜像 | `ghcr.io/xyaistudio/freeos:{version\|latest}` | | 本地 Compose 标签 | `octop:latest`(兼容别名;单 Python 进程) | | 安全公告 | [XYAIStudio/FreeOS](https://github.com/XYAIStudio/FreeOS/security/advisories/new) | -| PyPI 包名 | `octop`(有意保留的标识;CLI 是 `freeos`) | +| Python 发行包标识 | `octop`(内部兼容名;仅通过 GitHub Releases 分发) | -### 发版 / PyPI / Docker 门禁 +### 发版 / Docker 门禁 - **Docker Publish** 始终推 GHCR。Docker Hub 登录是 **可选** (`DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`)。没有 Hub secrets 不得让工作流变红。 -- **PyPI** 仍发布名为 `octop` 的发行版(`pypi` 环境的 `PYPI_API_TOKEN`)。这是 - Python 包名,不是腾讯云安全公告地址。 +- **Python 发行包**随 GitHub Release 附件发布。不要将 `octop` 发行包发布到 + PyPI:该名称已被上游 Octop 项目使用,会造成安装身份冲突。 - **飞牛 Docker `.fpk`** 的 compose 必须拉 `ghcr.io/xyaistudio/freeos`,不是 `ghcr.io/tencentcloud/octop`。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 960404d8..1f3bd784 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -100,7 +100,7 @@ source ~/.bashrc # Bash 安装脚本会把 `octop` 命令放入 `~/.octop/bin` 并加入 PATH,并在 `~/.octop/venv` 创建隔离环境;**不会改动系统 Python**。 -> **可选附加组件**:安装脚本支持通过 `--extras` 追加能力,例如浏览器自动化 `--extras browser`、飞书通道 `--extras channels-feishu`;也可用 `--version` 指定版本、`--mirror ` 使用国内 PyPI 镜像。更多选项见 [scripts/README.md](scripts/README.md)。 +> **可选附加组件**:安装脚本默认从 FreeOS GitHub 仓库安装,支持通过 `--extras` 追加能力,例如浏览器自动化 `--extras browser`、飞书通道 `--extras channels-feishu`;也可用 `--version` 指定 GitHub 版本标签、`--mirror ` 使用国内 PyPI 镜像下载 Python 依赖。更多选项见 [scripts/README.md](../scripts/README.md)。 ### 2.3 验证安装 @@ -466,7 +466,7 @@ Octop 支持两个方向的 ACP 集成: ``` **Q:如何升级?** -- `octop update`(若通过一键安装);或从 PyPI / 源码重新安装后重启服务。 +- `octop update`(若通过一键安装);或下载 GitHub Release 中的 Python 制品 / 从 FreeOS 源码重新安装后重启服务。 **Q:如何加入客户企业微信服务群?** - 请扫描下方二维码加入: diff --git a/pyproject.toml b/pyproject.toml index 63fae65b..0b9c75a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.0.10" +version = "0.1.0" 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/scripts/README.md b/scripts/README.md index 72997813..556c04cb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -25,12 +25,15 @@ ### 常用选项 ```bash -# 从 PyPI 安装最新版(默认) +# 从 FreeOS GitHub 源码安装最新版(默认) bash scripts/install.sh -# 指定版本 +# 从 FreeOS GitHub v0.1.0 标签安装 bash scripts/install.sh --version 0.1.0 +# 显式安装 PyPI 上游 Octop(不包含 FreeOS 下游修改) +bash scripts/install.sh --from-pypi + # 安装可选附加组件 bash scripts/install.sh --extras browser bash scripts/install.sh --extras browser,channels-feishu @@ -39,11 +42,11 @@ bash scripts/install.sh --extras browser,channels-feishu bash scripts/install.sh --from-source bash scripts/install.sh --from-source /path/to/orca -# 使用国内 PyPI 镜像加速依赖 +# 使用国内 PyPI 镜像加速 Python 依赖下载 bash scripts/install.sh --mirror https://mirrors.cloud.tencent.com/pypi/simple ``` -Windows PowerShell 等价参数:`-Version`、`-FromSource`、`-SourceDir`、`-Extras`。 +Windows PowerShell 等价参数:`-Version`、`-FromSource`、`-FromPyPI`、`-SourceDir`、`-Extras`。 ### 环境变量 @@ -65,7 +68,7 @@ octop run # 启动服务 → http://127.0.0.1:8088 --- -## 构建 PyPI wheel +## 构建 Python wheel(随 GitHub Release 分发) 先构建前端(产物写入 `src/octop/dashboard/`,与 `dashboard/vite.config.ts` 的 `outDir` 一致),再打包 wheel。 @@ -81,7 +84,7 @@ powershell -File scripts/wheel_build.ps1 输出:`dist/*.whl`、`dist/*.tar.gz` -发布前请确认 `pyproject.toml` 中的 `orcakit-harness-agent`、`harness-gateway` 等依赖已发布到 PyPI(`[tool.uv.sources]` 仅对 uv 本地开发生效,pip/PyPI 不读取)。 +Release workflow 将构建出的 wheel 和源码包附加到 GitHub Release。`octop` 是内部兼容用的发行包名,不要把 FreeOS 制品发布到 PyPI;安装脚本默认从 FreeOS GitHub 仓库安装。 --- diff --git a/scripts/install-octop.sh b/scripts/install-octop.sh index 493da44e..17703d34 100644 --- a/scripts/install-octop.sh +++ b/scripts/install-octop.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Octop 安装脚本 (macOS / Linux) -# 用法: bash scripts/install.sh # 从 PyPI 安装(默认) -# 或: bash scripts/install.sh --from-source # 从本地源码安装 +# 用法: bash scripts/install-octop.sh # 从 FreeOS GitHub 源码安装(默认) +# 或: bash scripts/install-octop.sh --from-pypi # 从 PyPI 安装 Octop(不含 FreeOS 下游改动) # 或: curl -fsSL /install.sh | bash # 远程安装 # # 将 Octop 安装到 ~/.octop,使用 uv 管理 Python 环境。 @@ -34,7 +34,7 @@ else fi VERSION="" -FROM_SOURCE=false +FROM_SOURCE=true EXTRAS="" PYPI_MIRROR="" @@ -81,9 +81,9 @@ Octop installer (macOS / Linux) Usage: bash install.sh [OPTIONS] Options: - --version Install a specific version (e.g. 0.1.0) [PyPI only] + --version Install a specific GitHub tag (e.g. 0.1.0; tag v0.1.0) --from-source [DIR] Install from source; clones the git repo if DIR is omitted - --from-pypi Install from PyPI (default) + --from-pypi Install upstream Octop from PyPI (does not include FreeOS changes) --extras Extra optional components (e.g. desktop, browser) --mirror Use a specific PyPI mirror (e.g. https://mirrors.aliyun.com/pypi/simple) -h, --help Show this help @@ -648,7 +648,11 @@ _clone_source_workspace() { git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/harness-agent" git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/harness-gateway" git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/harness-browser" - git clone --depth 1 "$OCTOP_REPO" "$workdir/orca" + if [ -n "$VERSION" ]; then + git clone --depth 1 --branch "v$VERSION" "$OCTOP_REPO" "$workdir/orca" + else + git clone --depth 1 "$OCTOP_REPO" "$workdir/orca" + fi } if [ "$FROM_SOURCE" = true ]; then diff --git a/scripts/install.bat b/scripts/install.bat index 8c500688..3980721e 100644 --- a/scripts/install.bat +++ b/scripts/install.bat @@ -2,16 +2,16 @@ setlocal EnableDelayedExpansion REM Octop Installer for Windows (cmd.exe) -REM Usage: install.bat [-Version X.Y.Z] [-FromSource] [-SourceDir DIR] [-Extras browser] [-Help] +REM Usage: install.bat [-Version X.Y.Z] [-FromSource | -FromPyPI] [-SourceDir DIR] [-Extras browser] [-Help] if defined OCTOP_HOME (set "OCTOP_HOME=%OCTOP_HOME%") else (set "OCTOP_HOME=%USERPROFILE%\.octop") set "OCTOP_VENV=%OCTOP_HOME%\venv" set "OCTOP_BIN=%OCTOP_HOME%\bin" set "PYTHON_VERSION=3.12" -if defined OCTOP_REPO (set "OCTOP_REPO=%OCTOP_REPO%") else (set "OCTOP_REPO=https://github.com/TencentCloud/Octop.git") +if defined OCTOP_REPO (set "OCTOP_REPO=%OCTOP_REPO%") else (set "OCTOP_REPO=https://github.com/XYAIStudio/FreeOS.git") set "ARG_VERSION=" -set "ARG_FROM_SOURCE=0" +set "ARG_FROM_SOURCE=1" set "ARG_SOURCE_DIR=" set "ARG_EXTRAS=" set "ARG_UV_PATH=" @@ -21,6 +21,7 @@ set "CONSOLE_AVAILABLE=0" if "%~1"=="" goto :done_args if /i "%~1"=="-Version" (set "ARG_VERSION=%~2" & shift & shift & goto :parse_args) if /i "%~1"=="-FromSource" (set "ARG_FROM_SOURCE=1" & shift & goto :parse_args) +if /i "%~1"=="-FromPyPI" (set "ARG_FROM_SOURCE=0" & shift & goto :parse_args) if /i "%~1"=="-SourceDir" (set "ARG_SOURCE_DIR=%~2" & shift & shift & goto :parse_args) if /i "%~1"=="-Extras" (set "ARG_EXTRAS=%~2" & shift & shift & goto :parse_args) if /i "%~1"=="-UvPath" (set "ARG_UV_PATH=%~2" & shift & shift & goto :parse_args) @@ -34,6 +35,7 @@ echo. echo Usage: install.bat [OPTIONS] echo -Version ^ Install specific version echo -FromSource Install from source +echo -FromPyPI Install upstream Octop from PyPI (does not include FreeOS changes) echo -SourceDir ^ Local source directory echo -Extras ^ Extra components (e.g. desktop, browser) echo -UvPath ^ Pre-installed uv.exe @@ -86,7 +88,11 @@ goto :install_verify where git >nul 2>&1 if errorlevel 1 (echo [octop] ERROR: git required & exit /b 1) set "CLONE_DIR=%TEMP%\octop-install-%RANDOM%" -git clone --depth 1 %OCTOP_REPO% "%CLONE_DIR%" +if defined ARG_VERSION ( + git clone --depth 1 --branch "v%ARG_VERSION%" %OCTOP_REPO% "%CLONE_DIR%" +) else ( + git clone --depth 1 %OCTOP_REPO% "%CLONE_DIR%" +) if errorlevel 1 exit /b 1 call :prepare_console "%CLONE_DIR%" uv pip install "%CLONE_DIR%%EXTRAS_SUFFIX%" --python "%VENV_PYTHON%" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 48c4cb1c..4b891c1d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -8,6 +8,7 @@ param( [string]$Version = "", [switch]$FromSource, + [switch]$FromPyPI, [string]$SourceDir = "", [string]$Extras = "", [string]$UvPath = "", @@ -20,12 +21,15 @@ $OctopHome = if ($env:OCTOP_HOME) { $env:OCTOP_HOME } else { Join-Path $HOME $OctopVenv = Join-Path $OctopHome "venv" $OctopBin = Join-Path $OctopHome "bin" $PythonVersion = "3.12" -$OctopRepo = if ($env:OCTOP_REPO) { $env:OCTOP_REPO } else { "https://github.com/TencentCloud/Octop.git" } +$OctopRepo = if ($env:OCTOP_REPO) { $env:OCTOP_REPO } else { "https://github.com/XYAIStudio/FreeOS.git" } function Write-Info { param([string]$Message) Write-Host "[octop] " -ForegroundColor Green -NoNewline; Write-Host $Message } function Write-Warn { param([string]$Message) Write-Host "[octop] " -ForegroundColor Yellow -NoNewline; Write-Host $Message } function Stop-WithError { param([string]$Message) Write-Host "[octop] ERROR: $Message" -ForegroundColor Red; exit 1 } +if ($FromSource -and $FromPyPI) { Stop-WithError "Choose only one of -FromSource or -FromPyPI." } +if (-not $FromPyPI) { $FromSource = $true } + if ($Help) { @" Octop Installer for Windows @@ -35,6 +39,7 @@ Usage: .\install.ps1 [OPTIONS] Options: -Version Install a specific version (e.g. 0.1.0) -FromSource Install from source (requires git, or use -SourceDir) + -FromPyPI Install upstream Octop from PyPI (does not include FreeOS changes) -SourceDir

Local source directory (used with -FromSource) -Extras Extra optional components (e.g. desktop, browser) -UvPath Path to a pre-installed uv.exe @@ -226,7 +231,11 @@ if ($FromSource) { } $cloneDir = Join-Path $env:TEMP "octop-install-$(Get-Random)" try { + if ($Version) { + git clone --depth 1 --branch "v$Version" $OctopRepo $cloneDir + } else { git clone --depth 1 $OctopRepo $cloneDir + } Prepare-Console $cloneDir uv pip install "${cloneDir}${ExtrasSuffix}" --python $VenvPython } finally { diff --git a/scripts/install.sh b/scripts/install.sh index 15138c8c..c90570ca 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Octop 安装脚本 (macOS / Linux) -# 用法: bash scripts/install.sh # 从 PyPI 安装(默认) -# 或: bash scripts/install.sh --from-source # 从本地源码安装 +# 用法: bash scripts/install.sh # 从 FreeOS GitHub 源码安装(默认) +# 或: bash scripts/install.sh --from-pypi # 从 PyPI 安装 Octop(不含 FreeOS 下游改动) # 或: curl -fsSL /install.sh | bash # 远程安装 # # 将 Octop 安装到 ~/.octop,使用 uv 管理 Python 环境。 @@ -34,7 +34,7 @@ else fi VERSION="" -FROM_SOURCE=false +FROM_SOURCE=true EXTRAS="" PYPI_MIRROR="" @@ -81,9 +81,9 @@ Octop installer (macOS / Linux) Usage: bash install.sh [OPTIONS] Options: - --version Install a specific version (e.g. 0.1.0) [PyPI only] + --version Install a specific GitHub tag (e.g. 0.1.0; tag v0.1.0) --from-source [DIR] Install from source; clones the git repo if DIR is omitted - --from-pypi Install from PyPI (default) + --from-pypi Install upstream Octop from PyPI (does not include FreeOS changes) --extras Extra optional components (e.g. desktop, browser) --mirror Use a specific PyPI mirror (e.g. https://mirrors.cloud.tencent.com/pypi/simple) -h, --help Show this help @@ -648,7 +648,11 @@ _clone_source_workspace() { git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/harness-agent" git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/harness-gateway" git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/harness-browser" - git clone --depth 1 "$OCTOP_REPO" "$workdir/orca" + if [ -n "$VERSION" ]; then + git clone --depth 1 --branch "v$VERSION" "$OCTOP_REPO" "$workdir/orca" + else + git clone --depth 1 "$OCTOP_REPO" "$workdir/orca" + fi } if [ "$FROM_SOURCE" = true ]; then diff --git a/src/octop/__init__.py b/src/octop/__init__.py index 307eb09e..d7857b72 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.10" +__version__ = "0.1.0" diff --git a/src/octop/cli/registry.py b/src/octop/cli/registry.py index 815890b7..d3887746 100644 --- a/src/octop/cli/registry.py +++ b/src/octop/cli/registry.py @@ -23,7 +23,7 @@ "admin": (".commands.admin", "admin", "Admin commands."), "version": (".commands.version", "version", "Show the installed octop version."), "completion": (".commands.completion", "completion", "Shell completion utilities."), - "update": (".commands.update", "update", "Check for and install a newer Octop release."), + "update": (".commands.update", "update", "Check for and install a newer FreeOS release."), "clean": (".commands.clean", "clean", "Remove CLI state or wipe all of ~/.octop."), "backup": (".commands.backup", "backup", "Export and restore Octop backups."), "acp": (".commands.acp", "acp_cmd", "Run Octop agent as ACP server (stdio)."), @@ -31,6 +31,6 @@ "org": ( ".commands.org", "org", - "Organization self-growth loop, governance, and module↔skill bridge.", + "Organization self-growth loop, governance, and module/skill bridge.", ), } diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index c9d42eb9..6764813e 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -2787,6 +2787,9 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: ) from octop.infra.agents.middleware.org_governance import OrgGovernanceMiddleware from octop.infra.agents.middleware.reasoning import ReasoningRequestMiddleware + from octop.infra.agents.middleware.system_message_cleanup import ( + SystemMessageCleanupMiddleware, + ) from octop.infra.agents.middleware.thread_artifacts import ThreadArtifactsMiddleware from octop.infra.agents.middleware.token_quota import TokenQuotaMiddleware from octop.infra.agents.middleware.workspace_image import ( @@ -2807,6 +2810,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: ), ReasoningRequestMiddleware(), OllamaToolFallbackMiddleware(), + SystemMessageCleanupMiddleware(), GroupMentionMiddleware(lambda: self._harness_manager), KnowledgeSearchHintMiddleware(), BrowserProfileMiddleware(), diff --git a/src/octop/infra/agents/middleware/system_message_cleanup.py b/src/octop/infra/agents/middleware/system_message_cleanup.py new file mode 100644 index 00000000..8485fa12 --- /dev/null +++ b/src/octop/infra/agents/middleware/system_message_cleanup.py @@ -0,0 +1,34 @@ +"""Keep checkpoint-resident system messages out of model requests.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse +from langchain_core.messages import AnyMessage, SystemMessage + + +class SystemMessageCleanupMiddleware(AgentMiddleware[Any, Any]): + """Remove stale system messages while preserving the compiled system prompt. + + Harness injects the active prompt through ``request.system_message``. Older + checkpoints may still contain ``SystemMessage`` entries in ``messages``; + strict chat templates such as Qwen reject those entries when they appear + after conversation messages. + """ + + async def awrap_model_call( + self, + request: ModelRequest[Any], + handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]], + ) -> ModelResponse[Any]: + messages: list[AnyMessage] = [ + message for message in request.messages if not isinstance(message, SystemMessage) + ] + if len(messages) != len(request.messages): + request = request.override(messages=messages) + return await handler(request) + + +__all__ = ["SystemMessageCleanupMiddleware"] diff --git a/tests/unit/agents/test_octop_builtin_skills.py b/tests/unit/agents/test_octop_builtin_skills.py index 65e7b79c..447412a4 100644 --- a/tests/unit/agents/test_octop_builtin_skills.py +++ b/tests/unit/agents/test_octop_builtin_skills.py @@ -175,11 +175,15 @@ def test_manager_refuses_overwrite_without_force(tmp_path: Path) -> None: refused = _run_manager(tmp_path, "install", str(source)) assert refused.returncode == 1 assert "ask before using --force" in refused.stdout - assert "First version" in (tmp_path / "skills" / "stable" / "SKILL.md").read_text() + assert "First version" in (tmp_path / "skills" / "stable" / "SKILL.md").read_text( + encoding="utf-8" + ) replaced = _run_manager(tmp_path, "install", str(source), "--force") assert replaced.returncode == 0, replaced.stderr or replaced.stdout - assert "Second version" in (tmp_path / "skills" / "stable" / "SKILL.md").read_text() + assert "Second version" in (tmp_path / "skills" / "stable" / "SKILL.md").read_text( + encoding="utf-8" + ) def test_manager_refuses_to_replace_itself(tmp_path: Path) -> None: diff --git a/tests/unit/agents/test_system_message_cleanup.py b/tests/unit/agents/test_system_message_cleanup.py new file mode 100644 index 00000000..a961f0b9 --- /dev/null +++ b/tests/unit/agents/test_system_message_cleanup.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +import pytest +from langchain.agents.middleware import ModelRequest, ModelResponse +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +from octop.infra.agents.middleware.system_message_cleanup import ( + SystemMessageCleanupMiddleware, +) + + +@pytest.mark.asyncio +async def test_removes_checkpoint_system_messages_and_keeps_compiled_prompt() -> None: + middleware = SystemMessageCleanupMiddleware() + compiled = SystemMessage(content="current prompt") + request = ModelRequest( + model=Mock(), + system_message=compiled, + messages=[ + HumanMessage(content="first"), + SystemMessage(content="stale bootstrap"), + AIMessage(content="reply"), + HumanMessage(content="next"), + ], + tools=[], + ) + seen: list[ModelRequest[Any]] = [] + expected = Mock(spec=ModelResponse) + + async def handler(value: ModelRequest[Any]) -> ModelResponse[Any]: + seen.append(value) + return expected + + result = await middleware.awrap_model_call(request, handler) + + assert result is expected + assert seen[0].system_message is compiled + assert [type(message) for message in seen[0].messages] == [ + HumanMessage, + AIMessage, + HumanMessage, + ] + + +@pytest.mark.asyncio +async def test_forwards_clean_request_without_replacing_it() -> None: + middleware = SystemMessageCleanupMiddleware() + request = ModelRequest(model=Mock(), messages=[HumanMessage(content="hello")], tools=[]) + seen: list[ModelRequest[Any]] = [] + expected = Mock(spec=ModelResponse) + + async def handler(value: ModelRequest[Any]) -> ModelResponse[Any]: + seen.append(value) + return expected + + result = await middleware.awrap_model_call(request, handler) + + assert result is expected + assert seen == [request] diff --git a/tests/unit/cli/test_completion_cmd.py b/tests/unit/cli/test_completion_cmd.py index d913958a..ebf7aeb7 100644 --- a/tests/unit/cli/test_completion_cmd.py +++ b/tests/unit/cli/test_completion_cmd.py @@ -28,13 +28,13 @@ def test_completion_install_appends_eval_line_idempotent( runner = CliRunner() r1 = runner.invoke(cli, ["completion", "install", "--shell", "bash", "--rc-file", str(rc)]) assert r1.exit_code == 0, r1.output - contents = rc.read_text() + contents = rc.read_text(encoding="utf-8") assert "_OCTOP_COMPLETE" in contents line_count_first = contents.count("_OCTOP_COMPLETE") r2 = runner.invoke(cli, ["completion", "install", "--shell", "bash", "--rc-file", str(rc)]) assert r2.exit_code == 0 - assert rc.read_text().count("_OCTOP_COMPLETE") == line_count_first + assert rc.read_text(encoding="utf-8").count("_OCTOP_COMPLETE") == line_count_first def test_completion_in_help() -> None: diff --git a/tests/unit/db/test_clip_thread_title.py b/tests/unit/db/test_clip_thread_title.py index c8eb211d..f78aa440 100644 --- a/tests/unit/db/test_clip_thread_title.py +++ b/tests/unit/db/test_clip_thread_title.py @@ -56,7 +56,7 @@ def test_migration_003_repairs_stored_hard_cuts(tmp_path: Path) -> None: ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) # Partial v2 columns so full migrate can finish. conn.execute("ALTER TABLE cron_jobs ADD COLUMN mcp_servers TEXT NOT NULL DEFAULT '[]'") diff --git a/tests/unit/db/test_db_pool.py b/tests/unit/db/test_db_pool.py index f6be371b..ce0f1e4e 100644 --- a/tests/unit/db/test_db_pool.py +++ b/tests/unit/db/test_db_pool.py @@ -136,7 +136,7 @@ def test_repair_legacy_schema_ensures_columns(tmp_path: Path) -> None: ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) # Simulate a schema version ahead of what the columns reflect conn.execute("UPDATE _schema_version SET version = 2") @@ -156,7 +156,7 @@ def test_migration_002_idempotent_when_column_already_present(tmp_path: Path) -> ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) conn.execute("ALTER TABLE cron_jobs ADD COLUMN mcp_servers TEXT NOT NULL DEFAULT '[]'") run_migrations(pool) @@ -179,7 +179,7 @@ def test_migration_005_preserves_populated_users_and_constraints(tmp_path: Path) Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" ) with pool.connect() as conn: - conn.executescript(initial_migration.read_text()) + conn.executescript(initial_migration.read_text(encoding="utf-8")) conn.execute("UPDATE _schema_version SET version = 4") conn.execute( """ @@ -293,7 +293,7 @@ def test_stuck_version_6_without_permissions_column_is_repaired(tmp_path: Path) ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) conn.execute("UPDATE _schema_version SET version = 6") run_migrations(pool) @@ -313,7 +313,7 @@ def test_schema_v10_without_projection_tables_is_repaired(tmp_path: Path) -> Non ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) conn.execute("UPDATE _schema_version SET version = 10") run_migrations(pool) @@ -346,7 +346,7 @@ def test_ahead_of_max_schema_version_clamps_to_max(tmp_path: Path) -> None: ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) conn.execute("UPDATE _schema_version SET version = 8") run_migrations(pool) @@ -374,7 +374,7 @@ def test_current_watermark_repairs_pre_v13_connectors_schema(tmp_path: Path) -> ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) conn.execute("UPDATE _schema_version SET version = 13") conn.execute( @@ -427,7 +427,7 @@ def test_pre_squash_schema_version_clamped_and_knowledge_tables_filled( ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) # Simulate a develop DB whose recorded version is ahead of the # consolidated max, so reconcile clamps and repairs knowledge tables. @@ -591,7 +591,9 @@ def test_v7_sqlite_sql_upgrades_legacy_text_pks(tmp_path: Path) -> None: "id, kb_id, filename, content_type, byte_size, created_at, updated_at) " "VALUES ('doc1', 'kb1', 'a.md', 'text/markdown', 1, 1, 1)" ) - conn.executescript((migrations / "007_resource_identity_and_profile.sql").read_text()) + conn.executescript( + (migrations / "007_resource_identity_and_profile.sql").read_text(encoding="utf-8") + ) with pool.connect() as conn: version = conn.execute("SELECT version FROM _schema_version").fetchone()[0] diff --git a/tests/unit/db/test_published_experts_repo.py b/tests/unit/db/test_published_experts_repo.py index ddf33df4..2b872893 100644 --- a/tests/unit/db/test_published_experts_repo.py +++ b/tests/unit/db/test_published_experts_repo.py @@ -114,8 +114,10 @@ def test_legacy_text_pk_published_experts_keep_public_id(tmp_path: Path) -> None pool = SqlitePool(tmp_path / "octop.db") migrations = Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations" with pool.connect() as conn: - conn.executescript((migrations / "001_initial.sql").read_text()) - conn.executescript((migrations / "005_shared_experts_sso_knowledge.sql").read_text()) + conn.executescript((migrations / "001_initial.sql").read_text(encoding="utf-8")) + conn.executescript( + (migrations / "005_shared_experts_sso_knowledge.sql").read_text(encoding="utf-8") + ) conn.execute( "INSERT INTO published_experts(" "id, slug, name, description, created_by, source_agent_id, " diff --git a/tests/unit/db/test_repo_knowledge.py b/tests/unit/db/test_repo_knowledge.py index ddb95d02..84b5859f 100644 --- a/tests/unit/db/test_repo_knowledge.py +++ b/tests/unit/db/test_repo_knowledge.py @@ -169,7 +169,7 @@ def test_migration_007_rebuilds_text_primary_keys(tmp_path: Path) -> None: ( Path(__file__).resolve().parents[3] / "src/octop/infra/db/migrations/001_initial.sql" - ).read_text() + ).read_text(encoding="utf-8") ) conn.execute("UPDATE _schema_version SET version = 6") conn.executescript( diff --git a/tests/unit/db/test_skill_package_icons.py b/tests/unit/db/test_skill_package_icons.py index 46f0cff0..746a8f01 100644 --- a/tests/unit/db/test_skill_package_icons.py +++ b/tests/unit/db/test_skill_package_icons.py @@ -58,8 +58,10 @@ def test_repair_legacy_schema_ensures_name_unique_index(tmp_path: Path) -> None: db_path = tmp_path / "octop.db" pool = SqlitePool(db_path) with pool.connect() as conn: - conn.executescript((_MIGRATIONS / "001_initial.sql").read_text()) - conn.executescript((_MIGRATIONS / "002_cron_mcp_and_skill_packages.sql").read_text()) + conn.executescript((_MIGRATIONS / "001_initial.sql").read_text(encoding="utf-8")) + conn.executescript( + (_MIGRATIONS / "002_cron_mcp_and_skill_packages.sql").read_text(encoding="utf-8") + ) conn.execute("DROP INDEX IF EXISTS idx_skill_packages_name") run_migrations(pool) with pool.connect() as conn: @@ -77,7 +79,7 @@ def test_migration_002_idempotent_when_icon_columns_already_present(tmp_path: Pa db_path = tmp_path / "octop.db" pool = SqlitePool(db_path) with pool.connect() as conn: - conn.executescript((_MIGRATIONS / "001_initial.sql").read_text()) + conn.executescript((_MIGRATIONS / "001_initial.sql").read_text(encoding="utf-8")) conn.executescript(_LEGACY_SKILL_PACKAGES_WITHOUT_ICONS) conn.execute("ALTER TABLE skill_packages ADD COLUMN icon_name TEXT NOT NULL DEFAULT ''") conn.execute("ALTER TABLE skill_packages ADD COLUMN icon_url TEXT NOT NULL DEFAULT ''") @@ -103,7 +105,7 @@ def test_repair_legacy_schema_adds_icon_columns_at_version_2(tmp_path: Path) -> db_path = tmp_path / "octop.db" pool = SqlitePool(db_path) with pool.connect() as conn: - conn.executescript((_MIGRATIONS / "001_initial.sql").read_text()) + conn.executescript((_MIGRATIONS / "001_initial.sql").read_text(encoding="utf-8")) conn.executescript(_LEGACY_SKILL_PACKAGES_WITHOUT_ICONS) conn.execute("UPDATE _schema_version SET version = 2") run_migrations(pool) diff --git a/tests/unit/db/test_skill_packages_repo.py b/tests/unit/db/test_skill_packages_repo.py index f340b39d..4d4354e9 100644 --- a/tests/unit/db/test_skill_packages_repo.py +++ b/tests/unit/db/test_skill_packages_repo.py @@ -52,8 +52,10 @@ def test_skill_package_repo_create_get(db: SqlitePool) -> None: def test_legacy_text_pk_skill_packages_keep_public_id(tmp_path: Path) -> None: pool = SqlitePool(tmp_path / "octop.db") with pool.connect() as conn: - conn.executescript((_MIGRATIONS / "001_initial.sql").read_text()) - conn.executescript((_MIGRATIONS / "002_cron_mcp_and_skill_packages.sql").read_text()) + conn.executescript((_MIGRATIONS / "001_initial.sql").read_text(encoding="utf-8")) + conn.executescript( + (_MIGRATIONS / "002_cron_mcp_and_skill_packages.sql").read_text(encoding="utf-8") + ) conn.execute( "INSERT INTO skill_packages(" "id, name, description, created_by, skill_count, icon_name, icon_url, " diff --git a/uv.lock b/uv.lock index 75ee7d32..6b273614 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "octop" -version = "0.0.10" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "acme" }, From 61ee8c2b0ec8116e2683bae94acc3a4437af7736 Mon Sep 17 00:00:00 2001 From: XYAIStudio Date: Sat, 10 Oct 2026 09:20:39 +0800 Subject: [PATCH 05/39] fix(release): enforce native checks and repair organization dependency drift --- .github/workflows/octop-desktop.yml | 39 +- .github/workflows/release.yml | 2 +- Makefile | 3 +- desktop/portable/bundle-org-sidecar.sh | 2 +- desktop/portable/verify_imports.py | 12 +- .../openxyos/backend/services/doc-parser.ts | 12 +- modules/openxyos/package-lock.json | 461 ++++++++---------- modules/openxyos/package.json | 16 +- modules/openxyos/scripts/test-doc-parser.ts | 47 ++ tests/unit/desktop/test_verify_imports.py | 38 ++ 10 files changed, 361 insertions(+), 271 deletions(-) create mode 100644 modules/openxyos/scripts/test-doc-parser.ts create mode 100644 tests/unit/desktop/test_verify_imports.py diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index 65d5c623..25633a9d 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -77,6 +77,16 @@ jobs: - name: Build frontend → src/octop/dashboard run: make build-frontend + - name: Check dashboard dependencies + working-directory: dashboard + run: npm audit --registry=https://registry.npmjs.org + + - name: Test dashboard + working-directory: dashboard + env: + NODE_OPTIONS: "--no-experimental-webstorage" + run: npm run lint && npm test + - uses: actions/upload-artifact@v7 with: name: dashboard-dist @@ -84,6 +94,27 @@ jobs: if-no-files-found: error retention-days: 7 + organization-quality: + name: Verify organization module + if: github.event_name != 'workflow_dispatch' || github.event.inputs.attach_from_run == '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: modules/openxyos/package-lock.json + - name: Install organization dependencies + working-directory: modules/openxyos + run: npm ci + - name: Check organization module + working-directory: modules/openxyos + run: npm run ci + - name: Check organization dependencies + working-directory: modules/openxyos + run: npm audit --registry=https://registry.npmjs.org + # Job-level `if` cannot read `matrix.*` (GitHub 422). Filter here so only # selected runners start — PRs → darwin-* + windows-*; dispatch honors `platforms`. select-platforms: @@ -134,7 +165,7 @@ jobs: package: name: "${{ matrix.plat }}" - needs: [frontend, select-platforms] + needs: [frontend, select-platforms, organization-quality] runs-on: ${{ matrix.os }} # Matrix runners are native for each plat; pin host detection so x64 Git Bash # on windows-11-arm does not mis-classify the job as windows-amd64 cross-build. @@ -177,7 +208,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: "20" + node-version: "24" cache: npm cache-dependency-path: modules/openxyos/package-lock.json @@ -260,6 +291,10 @@ jobs: - name: Install Wails v3 CLI run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 + - name: Test native desktop lifecycle and updater + working-directory: desktop/src + run: go test ./... + - name: Install NSIS if: runner.os == 'Windows' shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 199093df..b23978e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" cache: npm cache-dependency-path: dashboard/package-lock.json diff --git a/Makefile b/Makefile index 3b73053c..811bf76f 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ REPO_ROOT := $(shell pwd) DASHBOARD_DIR := $(REPO_ROOT)/dashboard DASHBOARD_DEST := $(REPO_ROOT)/src/octop/dashboard DIST_DIR := $(REPO_ROOT)/dist +FRONTEND_NODE_OPTIONS ?= --max-old-space-size=4096 # PyPI repository (override: make publish PYPI_REPO=testpypi) PYPI_REPO ?= pypi @@ -98,7 +99,7 @@ build-frontend: @echo "[build-frontend] Installing npm dependencies..." cd $(DASHBOARD_DIR) && npm ci @echo "[build-frontend] Building dashboard (output: $(DASHBOARD_DEST))..." - cd $(DASHBOARD_DIR) && NODE_ENV=production NODE_OPTIONS="--max-old-space-size=2048" npm run build + cd $(DASHBOARD_DIR) && NODE_ENV=production NODE_OPTIONS="$(FRONTEND_NODE_OPTIONS)" npm run build @echo "[build-frontend] Done." .PHONY: build-wheel diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index d275c458..d688c518 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -17,7 +17,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # shellcheck source=desktop/portable/_common.sh source "${REPO_ROOT}/desktop/portable/_common.sh" -NODE_VERSION="${NODE_VERSION:-20.19.5}" +NODE_VERSION="${NODE_VERSION:-24.21.0}" NODE_BASE_URL="${NODE_BASE_URL:-https://nodejs.org/dist/v${NODE_VERSION}}" node_archive_name() { diff --git a/desktop/portable/verify_imports.py b/desktop/portable/verify_imports.py index 7f08cf8c..a5179a0c 100755 --- a/desktop/portable/verify_imports.py +++ b/desktop/portable/verify_imports.py @@ -27,6 +27,9 @@ "cryptography.fernet", "langchain_core", "langchain_openai", + "octop.infra.agents.middleware.system_message_cleanup", + "octop.infra.server", + "octop.api.app", ) @@ -117,11 +120,12 @@ def main() -> int: except Exception as exc: errors.append(f"import {mod}: {type(exc).__name__}: {exc}") - # Sanity: launch.py must stay importable as a file (no compile errors). + # Compile the launcher shipped alongside packages/, not this checker. + launcher = packages.parent / "launch.py" try: - ast.parse(Path(__file__).read_text(encoding="utf-8")) - except SyntaxError as exc: - errors.append(f"self-parse: {exc}") + ast.parse(launcher.read_text(encoding="utf-8"), filename=str(launcher)) + except (OSError, SyntaxError) as exc: + errors.append(f"launch.py: {exc}") if errors: print("verify_imports FAILED:", file=sys.stderr) diff --git a/modules/openxyos/backend/services/doc-parser.ts b/modules/openxyos/backend/services/doc-parser.ts index 8c322942..c2aed9f0 100644 --- a/modules/openxyos/backend/services/doc-parser.ts +++ b/modules/openxyos/backend/services/doc-parser.ts @@ -5,11 +5,15 @@ export async function extractText(filePath: string, fileType: string): Promise=20.19.0" }, @@ -1896,6 +1898,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1917,6 +1920,7 @@ "resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1953,29 +1957,6 @@ "react": ">=16.8.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -2752,9 +2733,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", - "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -2771,13 +2752,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.3" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", - "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -2794,13 +2775,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.3" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", - "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -2808,7 +2789,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2818,9 +2799,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", - "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -2835,9 +2816,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", - "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -2852,9 +2833,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", - "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -2869,9 +2850,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", - "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -2886,9 +2867,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", - "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -2903,9 +2884,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", - "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -2920,9 +2901,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", - "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -2937,9 +2918,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", - "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -2954,9 +2935,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", - "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -2971,9 +2952,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", - "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -2988,9 +2969,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", - "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -3007,13 +2988,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.3" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", - "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -3030,13 +3011,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.3" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", - "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -3053,13 +3034,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.3" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", - "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -3076,13 +3057,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.3" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", - "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -3099,13 +3080,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.3" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", - "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -3122,13 +3103,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.3" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", - "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -3145,13 +3126,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", - "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -3168,13 +3149,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", - "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, @@ -3189,9 +3170,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", - "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], @@ -3199,7 +3180,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -3209,9 +3190,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", - "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -3229,9 +3210,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", - "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -3249,9 +3230,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", - "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -4791,6 +4772,7 @@ "resolved": "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4929,6 +4911,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5048,6 +5031,7 @@ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", @@ -5202,9 +5186,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5358,6 +5342,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5393,6 +5378,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5506,13 +5492,10 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", @@ -5617,13 +5600,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -5835,9 +5818,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5888,6 +5871,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", @@ -5945,6 +5929,7 @@ "version": "1.1.2", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/buffer-indexof-polyfill": { @@ -6228,31 +6213,16 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/concurrently": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", - "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "version": "9.2.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.5.tgz", + "integrity": "sha512-YMZlvWrTPQEhjKp60XSmMGt60+pffbv6DyRnOMvVO98+J7aF8w8Ui9FhdcOwvSixc/kyR6SoVvmejcn/2lZBqg==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.9.0", + "shell-quote": "1.12.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -6484,6 +6454,7 @@ "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -6941,6 +6912,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -7324,9 +7296,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", - "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7738,6 +7710,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8007,15 +7980,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/exceljs/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz", @@ -8140,9 +8104,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -8214,9 +8178,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -9017,9 +8981,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" @@ -9582,13 +9546,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/js-yaml/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/jsdom": { "version": "29.1.1", "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz", @@ -9881,9 +9838,9 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.10.tgz", + "integrity": "sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==", "dev": true, "funding": [ "https://opencollective.com/katex", @@ -9891,20 +9848,20 @@ ], "license": "MIT", "dependencies": { - "commander": "^8.3.0" + "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=22.12.0" } }, "node_modules/keyv": { @@ -10486,19 +10443,17 @@ } }, "node_modules/mammoth": { - "version": "1.12.0", - "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", - "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz", + "integrity": "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==", "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", - "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", - "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, @@ -10699,14 +10654,13 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", - "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -11129,6 +11083,7 @@ "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -11386,9 +11341,9 @@ "license": "MIT" }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -11396,6 +11351,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -11484,6 +11443,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11493,6 +11453,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11588,9 +11549,9 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -12145,9 +12106,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", - "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -12162,31 +12123,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.4", - "@img/sharp-darwin-x64": "0.35.4", - "@img/sharp-freebsd-wasm32": "0.35.4", - "@img/sharp-libvips-darwin-arm64": "1.3.3", - "@img/sharp-libvips-darwin-x64": "1.3.3", - "@img/sharp-libvips-linux-arm": "1.3.3", - "@img/sharp-libvips-linux-arm64": "1.3.3", - "@img/sharp-libvips-linux-ppc64": "1.3.3", - "@img/sharp-libvips-linux-riscv64": "1.3.3", - "@img/sharp-libvips-linux-s390x": "1.3.3", - "@img/sharp-libvips-linux-x64": "1.3.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", - "@img/sharp-libvips-linuxmusl-x64": "1.3.3", - "@img/sharp-linux-arm": "0.35.4", - "@img/sharp-linux-arm64": "0.35.4", - "@img/sharp-linux-ppc64": "0.35.4", - "@img/sharp-linux-riscv64": "0.35.4", - "@img/sharp-linux-s390x": "0.35.4", - "@img/sharp-linux-x64": "0.35.4", - "@img/sharp-linuxmusl-arm64": "0.35.4", - "@img/sharp-linuxmusl-x64": "0.35.4", - "@img/sharp-webcontainers-wasm32": "0.35.4", - "@img/sharp-win32-arm64": "0.35.4", - "@img/sharp-win32-ia32": "0.35.4", - "@img/sharp-win32-x64": "0.35.4" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -12231,9 +12192,9 @@ } }, "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "dev": true, "license": "MIT", "engines": { @@ -12353,9 +12314,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12392,12 +12353,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/sql.js": { "version": "1.14.1", "resolved": "https://registry.npmmirror.com/sql.js/-/sql.js-1.14.1.tgz", @@ -12757,6 +12712,7 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -12928,6 +12884,7 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -13064,18 +13021,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13347,16 +13299,16 @@ } }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/vary": { @@ -13374,6 +13326,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -14203,9 +14156,9 @@ } }, "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 9a9bf822..84671b5b 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -11,7 +11,7 @@ "postinstall": "node scripts/patch-express-types.cjs", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", - "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": "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:doc-parser && 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", @@ -35,7 +35,8 @@ "test:module-settings": "tsx scripts/test-module-settings.ts", "test:agent-studio": "tsx scripts/test-agent-studio.ts", "test:llm-onboarding": "tsx scripts/test-llm-onboarding.ts", - "test:freeos-ingest": "tsx scripts/test-freeos-ingest.ts" + "test:freeos-ingest": "tsx scripts/test-freeos-ingest.ts", + "test:doc-parser": "tsx scripts/test-doc-parser.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -65,7 +66,7 @@ "sql.js": "^1.11.0", "ssh2": "^1.17.0", "unzipper": "^0.12.3", - "uuid": "^10.0.0", + "uuid": "^11.1.1", "ws": "^8.21.0" }, "devDependencies": { @@ -108,6 +109,13 @@ "node": ">=20.19" }, "overrides": { - "qs": "6.16.0" + "qs": "6.16.0", + "katex": "^0.18.2", + "exceljs": { + "uuid": "^11.1.1" + }, + "mammoth": { + "argparse": "^2.0.1" + } } } diff --git a/modules/openxyos/scripts/test-doc-parser.ts b/modules/openxyos/scripts/test-doc-parser.ts new file mode 100644 index 00000000..eb6791d3 --- /dev/null +++ b/modules/openxyos/scripts/test-doc-parser.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import PDFDocument from "pdfkit"; +import { Document, Packer, Paragraph } from "docx"; +import ExcelJS from "exceljs"; +import { extractText } from "../backend/services/doc-parser"; + +async function main() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "freeos-doc-parser-")); + try { + const text = "FreeOS document import acceptance"; + const pdf = new PDFDocument(); + const chunks: Buffer[] = []; + const finished = new Promise((resolve, reject) => { + pdf.on("data", (chunk: Buffer) => chunks.push(chunk)); + pdf.on("end", () => resolve(Buffer.concat(chunks))); + pdf.on("error", reject); + }); + pdf.text(text); + pdf.end(); + const pdfPath = path.join(root, "sample.pdf"); + await fs.writeFile(pdfPath, await finished); + assert.ok((await extractText(pdfPath, ".pdf")).includes(text)); + + const docxPath = path.join(root, "sample.docx"); + const docx = new Document({ sections: [{ children: [new Paragraph(text)] }] }); + await fs.writeFile(docxPath, await Packer.toBuffer(docx)); + assert.equal(await extractText(docxPath, ".docx"), text); + + // Exercise ExcelJS with the patched, CommonJS-compatible UUID dependency. + const workbook = new ExcelJS.Workbook(); + workbook.addWorksheet("Import").addRow([text]); + const restored = new ExcelJS.Workbook(); + await restored.xlsx.load(await workbook.xlsx.writeBuffer()); + assert.equal(restored.getWorksheet("Import")?.getCell("A1").value, text); + console.log("PDF, DOCX and XLSX import/export acceptance passed"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/unit/desktop/test_verify_imports.py b/tests/unit/desktop/test_verify_imports.py new file mode 100644 index 00000000..22a2a3c2 --- /dev/null +++ b/tests/unit/desktop/test_verify_imports.py @@ -0,0 +1,38 @@ +"""The portable smoke gate must inspect the launcher that users will run.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("launcher", ["print('ready')\n", "def broken(\n", None]) +def test_shipped_launcher_is_checked(tmp_path: Path, monkeypatch, capsys, launcher): + checker = Path(__file__).parents[3] / "desktop/portable/verify_imports.py" + spec = importlib.util.spec_from_file_location("portable_verify_imports", checker) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + packages = tmp_path / "packages" + packages.mkdir() + requirements = tmp_path / "requirements.txt" + requirements.write_text("", encoding="utf-8") + if launcher is not None: + (tmp_path / "launch.py").write_text(launcher, encoding="utf-8") + monkeypatch.setattr(module, "_SMOKE_IMPORTS", ()) + monkeypatch.setattr(module.site, "addsitedir", lambda _: None) + monkeypatch.setattr(module, "_installed_version", lambda _: None) + monkeypatch.setattr( + sys, + "argv", + [str(checker), "--packages", str(packages), "--requirements", str(requirements)], + ) + result = module.main() + if launcher == "print('ready')\n": + assert result == 0 + else: + assert result == 1 + assert "launch.py:" in capsys.readouterr().err From 75e9e517268432208fb8ce44d57cb2f5e874eb03 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 21:37:53 +0000 Subject: [PATCH 06/39] fix(release): close 0.1.0 CI and sidecar security blockers Refresh the openXYOS lock so npm ci resolves on Node 24 and require Node >=22.12. Wait for the SearchConfig source label before asserting it; that was the Node 24 dashboard failure while the spinner was still mounted. Bind the organization sidecar to 127.0.0.1 from the server default and every desktop launcher. Seed local accounts only under FREEOS_ORG_LOCAL_TEST, with a random password stored beside the database and a forced change on first sign-in. The UI no longer shows a shared demo password. Mark rc/a/b/dev tags as GitHub prereleases in the release, desktop attach, and FNOS attach workflows. Collapse the changelog to a single 0.1.0 section and stop README_CN from directing installs at PyPI octop. Order desktop version compare so a final release sorts above its rc. Co-authored-by: haoboy007-sudo --- .github/workflows/fnos-build-fpk.yml | 6 + .github/workflows/octop-desktop.yml | 9 ++ .github/workflows/release.yml | 6 + CHANGELOG.md | 28 ++-- README.md | 4 +- README_CN.md | 14 +- .../Settings/SearchConfig/index.test.tsx | 12 +- desktop/portable/bundle-org-sidecar.sh | 6 + .../src/build/windows/nsis/start-sidecar.ps1 | 8 +- desktop/src/download.go | 83 ++++++++++- desktop/src/download_test.go | 7 + desktop/src/process.go | 28 ++-- desktop/src/process_sidecar.go | 3 + desktop/src/process_sidecar_test.go | 6 + docs/adr/001-single-process-model.md | 2 +- modules/openxyos/backend/db.ts | 6 + modules/openxyos/backend/middleware.ts | 24 +++ modules/openxyos/backend/routes/auth.ts | 40 +++++ modules/openxyos/backend/seed.ts | 139 ++++++++++++++---- modules/openxyos/backend/server.ts | 2 +- .../openxyos/frontend/src/pages/AuthPage.tsx | 73 +++++++-- .../frontend/src/pages/OpenHomePage.tsx | 36 +---- modules/openxyos/frontend/src/stores/auth.ts | 40 ++++- modules/openxyos/package-lock.json | 51 +++---- modules/openxyos/package.json | 2 +- scripts/release_prerelease.py | 32 ++++ src/octop/modules/org_os/sidecar_launch.py | 1 + .../unit/desktop/test_openxyos_provisioner.py | 2 + tests/unit/test_release_prerelease.py | 27 ++++ tests/unit/test_sidecar_launch.py | 1 + 30 files changed, 549 insertions(+), 149 deletions(-) create mode 100644 scripts/release_prerelease.py create mode 100644 tests/unit/test_release_prerelease.py diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 6786038e..f8c06e06 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -273,6 +273,10 @@ jobs: exit 1 fi + - name: Classify pre-release + id: channel + run: python3 scripts/release_prerelease.py "${{ needs.version.outputs.tag }}" >> "$GITHUB_OUTPUT" + - name: Attach fpk to GitHub Release uses: softprops/action-gh-release@v2 with: @@ -280,5 +284,7 @@ jobs: files: dist/*.fpk fail_on_unmatched_files: true generate_release_notes: false + prerelease: ${{ steps.channel.outputs.prerelease }} + make_latest: ${{ steps.channel.outputs.prerelease == 'true' && 'false' || 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index 25633a9d..1c508e60 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -360,6 +360,8 @@ jobs: contents: write actions: read steps: + - uses: actions/checkout@v4 + - name: Resolve release tag id: rel run: | @@ -374,6 +376,10 @@ jobs: fi echo "tag=${tag}" >> "$GITHUB_OUTPUT" + - name: Classify pre-release + id: channel + run: python3 scripts/release_prerelease.py "${{ steps.rel.outputs.tag }}" >> "$GITHUB_OUTPUT" + # v8 required for archive:false artifacts. skip-decompress keeps the # uploaded .zip / .tar.gz / .dmg / .exe intact — default unzip turns portable # zips into directories, so files: release-assets/* would skip them. @@ -404,4 +410,7 @@ jobs: files: release-assets/* fail_on_unmatched_files: true # Upsert: works whether Release workflow already created the release. + # Keep rc/a/b/dev tags as prereleases so attaching assets cannot mark them latest. generate_release_notes: false + prerelease: ${{ steps.channel.outputs.prerelease }} + make_latest: ${{ steps.channel.outputs.prerelease == 'true' && 'false' || 'true' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b23978e7..95ffa435 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,12 +125,18 @@ jobs: python3 scripts/release_download_links.py "$version" >> .release-notes.md fi + - name: Classify pre-release + id: channel + run: python3 scripts/release_prerelease.py "${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + - name: Create release uses: softprops/action-gh-release@v2 with: body_path: .release-notes.md files: dist/* generate_release_notes: true + prerelease: ${{ steps.channel.outputs.prerelease }} + make_latest: ${{ steps.channel.outputs.prerelease == 'true' && 'false' || 'true' }} # workflow_run / release:published are unreliable when Release itself was # started via GITHUB_TOKEN workflow_dispatch (Auto Tag). Explicitly cascade. diff --git a/CHANGELOG.md b/CHANGELOG.md index 15367e0a..09aa70ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,28 +4,32 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。 -## [Unreleased] +## [0.1.0] - TBD + +FreeOS **0.1.0** is an independent semver baseline. It does not track or align with Octop 1.0.x. Python wheels stay on GitHub Releases; this release does not publish the colliding `octop` name to PyPI. + +### Upgrade from 0.0.10 + +- In-app update and a manual reinstall both keep `~/.freeos` (legacy `~/.octop` is still recognized): sessions, local models, knowledge bases, organization data, and `config.json`. +- Desktop portable upgrades copy a SQLite backup under `$FREEOS_HOME/backups` before replacing the runtime, and checkpoint the WAL before the process restarts. +- A `config.json` that cannot be parsed is left in place and reported. It is not rewritten as an empty file. +- Roll back by installing the **v0.0.10** desktop package again, or restore a backup with `octop backup restore`. Do not point install scripts at PyPI `octop`; that package is upstream Octop, not this release. Use [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) or `scripts/install.sh` (add `--from-pypi` only when you explicitly want upstream Octop). ### Changed -- Publish Python distributions as GitHub Release assets only; the FreeOS release workflow no longer publishes the colliding `octop` package name to PyPI, and install scripts default to the FreeOS GitHub source. +- Publish Python distributions as GitHub Release assets only. The release workflow no longer publishes to PyPI, and install scripts default to the FreeOS GitHub source. +- The bundled openXYOS sidecar listens on `127.0.0.1` by default. Local bootstrap accounts are created only when `FREEOS_ORG_LOCAL_TEST=1`, with a random password in `{data}/bootstrap-credentials.txt` that must be changed on first sign-in. The UI no longer embeds a shared demo password. ### Fixed - Remove checkpoint-resident system messages before model calls while preserving the active compiled prompt, preventing strict Qwen chat templates from failing when an older conversation contains a stale bootstrap message. - Detect models from an already-running Ollama daemon even when FreeOS-managed Ollama startup is disabled; model discovery never starts Ollama unless the user enabled it. -- Preserve `config.json` when it is malformed: startup and all configuration writers now report the file and parser position, avoid leaking stored credentials, and use atomic replacement instead of silently rebuilding an empty configuration. - -## [0.1.0] - 2026-10-07 +- Preserve `config.json` when it is malformed: startup and configuration writers now report the file and parser position, avoid leaking stored credentials, and use atomic replacement instead of silently rebuilding an empty configuration. Absorbed from upstream Octop #730 / #738. +- Checkpoint the SQLite WAL before the desktop process restarts so an in-app update does not drop the latest writes. Absorbed from upstream Octop #918. -### Changed - -- Publish the successor to 0.0.10 as 0.1.0, with Python distributions attached to GitHub Releases only. - -### Fixed +### Not in 0.1.0 -- Remove stale checkpoint system messages before model calls, detect models from an already-running Ollama daemon without starting it, and preserve malformed `config.json` files instead of silently replacing them. -- Improve desktop update safety by checkpointing SQLite WAL before restart. +Harness migration, cloud bridge default-on, default LDAP/SSO, Teams, and a whole-repo catch-up to Octop are out of scope. ## [0.0.10] - 2026-10-03 diff --git a/README.md b/README.md index 55df7923..237b8afa 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.6 托管 Node + iframe(延续 0.0.3 过渡桥)、以及可选的完整 openXYOS Node 栈(`127.0.0.1:3780`,导出/同步/高级部署)都是 **过渡桥**,终态是把 openXYOS 迁入宿主。组织能力像工作室里另一间可独立布置的房间,与日常对话、助手协作同在一个 FreeOS,两种进入方式不捏成一种。 +4. 保存、导出或发布到账号时再在本机完成注册或登录(更广的服务或授权不挡起步)。侧栏 **Organization** 当前默认走宿主内组织能力;桌面安装包附带的托管 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 fd7b3dcc..93505529 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,10 +11,9 @@

Python 3.12+ License: MIT - Version + Version 上游:Octop 组织模块:openXYOS - PyPI Code Style: Ruff GitHub stars GitHub forks @@ -248,14 +247,7 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install 桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。独立商业组织站见 [docs/org-export.md](docs/org-export.md)(`freeos org export-standalone`,默认导出完整 openXYOS 源码树),不是默认安装器里的第二套 Node。 -**备选 — PyPI**(若你已自行管理 Python 环境): - -```bash -pip install octop -# 可选:pip install "octop[browser]" -# 可选本地 ONNX Embedding 模型缓存(设置 → 模型 → 本地):pip install "octop[local-embedding]" -# 仅下载目录模型到 ~/.octop/embedding_models;不用于对话,也不接入 Memory。 -``` +**不要**从 PyPI 安装 `octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS 0.1.0。桌面安装包从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载;终端安装用上面的安装脚本(默认拉取 FreeOS GitHub 源码)。只有在你明确要上游 Octop 时才加 `--from-pypi`。可选组件仍通过安装脚本传递,例如 `--extras browser`。 从源码用 uv 开发时: @@ -343,7 +335,7 @@ docker run -d \ | 远程一键安装 | Windows | `irm …/octop/install.ps1 \| iex` 或 `install.bat` | | 本地脚本 | macOS / Linux | `bash scripts/install.sh` | | 本地脚本 | Windows | `scripts\install.bat` 或 `install.ps1` | -| PyPI | 全平台 | `pip install octop` 或 `pip install "octop[browser]"` | +| GitHub Releases | 全平台 | 桌面安装包与 wheel。不要安装 PyPI 上的上游 `octop` | | Docker | 全平台 | `docker/docker-compose.yml` | 所有安装脚本均在 `~/.octop/venv` 创建隔离环境,并通过 `~/.octop/bin/octop` 包装 CLI,不会影响系统 Python。 diff --git a/dashboard/src/pages/Settings/SearchConfig/index.test.tsx b/dashboard/src/pages/Settings/SearchConfig/index.test.tsx index d743f693..4f1ebec2 100644 --- a/dashboard/src/pages/Settings/SearchConfig/index.test.tsx +++ b/dashboard/src/pages/Settings/SearchConfig/index.test.tsx @@ -35,17 +35,21 @@ describe("", () => { api.listEnvs.mockResolvedValue(envResp([])); render(); - await waitFor(() => expect(api.listEnvs).toHaveBeenCalled()); - expect(screen.getByText("当前搜索源:内置搜索")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("当前搜索源:内置搜索")).toBeInTheDocument(); + }); + expect(api.listEnvs).toHaveBeenCalled(); }); it("shows the configured provider as the current search source", async () => { api.listEnvs.mockResolvedValue(envResp(["TAVILY_API_KEY"])); render(); - await waitFor(() => expect(api.listEnvs).toHaveBeenCalled()); - expect(screen.getByText("当前搜索源:Tavily")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("当前搜索源:Tavily")).toBeInTheDocument(); + }); + expect(api.listEnvs).toHaveBeenCalled(); }); }); diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index d688c518..f8172c22 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -213,11 +213,14 @@ ENV fi export NODE_ENV=production export PORT +export HOST=127.0.0.1 export DB_DIALECT=sqlite export DATABASE_PATH="${DATA_DIR}/xiongyuan.db" export AIR_GAP_MODE=true export ALLOW_PUBLIC_REGISTRATION="${ALLOW_PUBLIC_REGISTRATION:-false}" export SEED_DEMO_DATA="${SEED_DEMO_DATA:-false}" +export FREEOS_ORG_INTEGRATED="${FREEOS_ORG_INTEGRATED:-1}" +export FREEOS_ORG_LOCAL_TEST="${FREEOS_ORG_LOCAL_TEST:-1}" export JWT_SECRET COOKIE_SECRET export CORS_ORIGIN="${CORS_ORIGIN:-http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900}" cd "${APP}" @@ -252,11 +255,14 @@ if not defined COOKIE_SECRET ( >> "%FREEOS_ORG_DATA%\sidecar.env" echo COOKIE_SECRET=%COOKIE_SECRET% set "NODE_ENV=production" set "PORT=%FREEOS_ORG_SIDECAR_PORT%" +set "HOST=127.0.0.1" set "DB_DIALECT=sqlite" set "DATABASE_PATH=%FREEOS_ORG_DATA%\xiongyuan.db" set "AIR_GAP_MODE=true" if not defined ALLOW_PUBLIC_REGISTRATION set "ALLOW_PUBLIC_REGISTRATION=false" if not defined SEED_DEMO_DATA set "SEED_DEMO_DATA=false" +if not defined FREEOS_ORG_INTEGRATED set "FREEOS_ORG_INTEGRATED=1" +if not defined FREEOS_ORG_LOCAL_TEST set "FREEOS_ORG_LOCAL_TEST=1" if not defined CORS_ORIGIN set "CORS_ORIGIN=http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900" cd /d "%APP%" "%NODE%" --import tsx backend/server.ts diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 81cb9563..84aed5d9 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -234,6 +234,7 @@ if (-not $ingest) { $ingest = [guid]::NewGuid().ToString('N') + [guid]::NewGuid( $env:NODE_ENV = 'production' $env:PORT = '3780' +$env:HOST = '127.0.0.1' $env:DB_DIALECT = 'sqlite' $env:DATABASE_PATH = Join-Path $data 'xiongyuan.db' $env:AIR_GAP_MODE = 'true' @@ -249,6 +250,8 @@ $env:CORS_ORIGIN = 'http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1 $env:FREEOS_HOME = $freeosHome $env:OCTOP_HOME = $freeosHome $env:FREEOS_ORG_SIDECAR_PORT = '3780' +$env:FREEOS_ORG_INTEGRATED = '1' +$env:FREEOS_ORG_LOCAL_TEST = '1' $compiled = Join-Path $app 'backend-dist\server.js' if (Test-Path -LiteralPath $compiled) { $argv = @('backend-dist/server.js') @@ -273,6 +276,7 @@ if ($app -eq $live) { $layout = 'top-level' } "argv=$($argv -join ' ')" "NODE_ENV=$($env:NODE_ENV)" "PORT=$($env:PORT)" + "HOST=$($env:HOST)" "JWT_SECRET set=$([bool]$env:JWT_SECRET)" "COOKIE_SECRET set=$([bool]$env:COOKIE_SECRET)" "CORS_ORIGIN=$($env:CORS_ORIGIN)" @@ -294,10 +298,10 @@ $psi.Arguments = [string]::Join(' ', $quoted) $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true foreach ($name in @( - 'NODE_ENV', 'PORT', 'DB_DIALECT', 'DATABASE_PATH', 'AIR_GAP_MODE', + 'NODE_ENV', 'PORT', 'HOST', 'DB_DIALECT', 'DATABASE_PATH', 'AIR_GAP_MODE', 'SEED_DEMO_DATA', 'ALLOW_PUBLIC_REGISTRATION', 'JWT_SECRET', 'COOKIE_SECRET', 'FREEOS_INGEST_TOKEN', 'CORS_ORIGIN', 'FREEOS_HOME', 'OCTOP_HOME', - 'FREEOS_ORG_SIDECAR_PORT' + 'FREEOS_ORG_SIDECAR_PORT', 'FREEOS_ORG_INTEGRATED', 'FREEOS_ORG_LOCAL_TEST' )) { $val = [Environment]::GetEnvironmentVariable($name, 'Process') if ($null -ne $val -and $val -ne '') { diff --git a/desktop/src/download.go b/desktop/src/download.go index 194f4db2..49c2122d 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -546,7 +546,88 @@ func compareVersions(left, right string) int { return 1 } } - return 0 + return compareVersionSuffix(versionSuffix(left), versionSuffix(right)) +} + +// versionSuffix is the non-numeric tail of a PEP 440-ish version (rc1, a1, dev1). +func versionSuffix(version string) string { + var suffix strings.Builder + for _, segment := range strings.Split(version, ".") { + index := 0 + for index < len(segment) && segment[index] >= '0' && segment[index] <= '9' { + index++ + } + if index < len(segment) { + suffix.WriteString(segment[index:]) + } + } + return suffix.String() +} + +func compareVersionSuffix(left, right string) int { + if left == right { + return 0 + } + // 0.1.0rc1 < 0.1.0. The Python update client does full PEP 440 ordering; + // this tie-break only keeps a downloaded pre-release behind its final. + if left == "" { + return 1 + } + if right == "" { + return -1 + } + leftKind, leftN, leftOK := preReleaseRank(left) + rightKind, rightN, rightOK := preReleaseRank(right) + if leftOK && rightOK { + if leftKind != rightKind { + if leftKind < rightKind { + return -1 + } + return 1 + } + if leftN < rightN { + return -1 + } + if leftN > rightN { + return 1 + } + return 0 + } + if left < right { + return -1 + } + return 1 +} + +func preReleaseRank(suffix string) (kind int, n int, ok bool) { + for _, item := range []struct { + prefix string + kind int + }{ + {"alpha", 1}, + {"beta", 2}, + {"preview", 2}, + {"pre", 2}, + {"rc", 3}, + {"dev", 0}, + {"a", 1}, + {"b", 2}, + {"c", 2}, + } { + if !strings.HasPrefix(suffix, item.prefix) { + continue + } + rest := strings.TrimLeft(suffix[len(item.prefix):], "._-") + if rest == "" { + return item.kind, 0, true + } + value, err := strconv.Atoi(rest) + if err != nil { + return 0, 0, false + } + return item.kind, value, true + } + return 0, 0, false } func versionPart(segment string) int { diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index aeb62c9e..34a70e1e 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -693,6 +693,13 @@ func TestCompareVersions(t *testing.T) { {"0.9.31", "0.9.32", -1}, {"1.0", "1.0.0", 0}, {"0.9.32rc1", "0.9.31", 1}, + {"0.1.0rc1", "0.0.10", 1}, + {"0.1.0rc1", "0.1.0", -1}, + {"0.1.0", "0.1.0rc1", 1}, + {"0.1.0rc1", "0.1.0rc2", -1}, + {"0.1.0rc2", "0.1.0rc10", -1}, + {"0.1.0a1", "0.1.0rc1", -1}, + {"0.1.0.dev1", "0.1.0a1", -1}, } { if got := compareVersions(test.left, test.right); got != test.want { t.Fatalf("compareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want) diff --git a/desktop/src/process.go b/desktop/src/process.go index fc85cfac..e1beda64 100644 --- a/desktop/src/process.go +++ b/desktop/src/process.go @@ -12,10 +12,18 @@ import ( ) func mustEnv(cmd *exec.Cmd, extra map[string]string) { - cmd.Env = os.Environ() + filtered := make([]string, 0, len(os.Environ())+len(extra)) + for _, item := range os.Environ() { + key, _, _ := strings.Cut(item, "=") + if _, override := extra[key]; override { + continue + } + filtered = append(filtered, item) + } for key, value := range extra { - cmd.Env = append(cmd.Env, key+"="+value) + filtered = append(filtered, key+"="+value) } + cmd.Env = filtered } const defaultSidecarPort = 3780 @@ -23,14 +31,14 @@ const defaultSidecarPort = 3780 func hostLaunchEnv(root string, port int) map[string]string { home := productHome() env := map[string]string{ - "FREEOS_HOME": home, - "OCTOP_HOME": home, - "OCTOP_GREEN_PACKAGES": filepath.Join(root, "packages"), - "PYTHONNOUSERSITE": "1", - "PYTHONUTF8": "1", - "PYTHONIOENCODING": "utf-8", - "PYTHONPATH": "", - "FREEOS_ORG_ENABLE": "1", + "FREEOS_HOME": home, + "OCTOP_HOME": home, + "OCTOP_GREEN_PACKAGES": filepath.Join(root, "packages"), + "PYTHONNOUSERSITE": "1", + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + "PYTHONPATH": "", + "FREEOS_ORG_ENABLE": "1", // Desktop always claims the openXYOS module. Missing Node is a // packaging/startup failure, not a reason to unset this flag. "FREEOS_ORG_INTEGRATED": "1", diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index 76684392..e6540a18 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -304,6 +304,7 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) return map[string]string{ "NODE_ENV": "production", "PORT": strconv.Itoa(defaultSidecarPort), + "HOST": "127.0.0.1", "DB_DIALECT": "sqlite", "DATABASE_PATH": filepath.Join(sidecarDataDir(home), "xiongyuan.db"), "AIR_GAP_MODE": "true", @@ -316,6 +317,8 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) "FREEOS_HOME": home, "OCTOP_HOME": home, "FREEOS_ORG_SIDECAR_PORT": strconv.Itoa(defaultSidecarPort), + "FREEOS_ORG_INTEGRATED": "1", + "FREEOS_ORG_LOCAL_TEST": "1", }, nil } diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index 62fa0f3b..52265cda 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -58,6 +58,12 @@ func TestSidecarLaunchEnvPointsAtHomeDatabase(t *testing.T) { if !strings.Contains(env["CORS_ORIGIN"], "http://[::1]:3780") { t.Fatalf("CORS_ORIGIN missing ipv6 self origin: %q", env["CORS_ORIGIN"]) } + if env["HOST"] != "127.0.0.1" { + t.Fatalf("HOST=%q want 127.0.0.1", env["HOST"]) + } + if env["FREEOS_ORG_LOCAL_TEST"] != "1" { + t.Fatalf("FREEOS_ORG_LOCAL_TEST=%q", env["FREEOS_ORG_LOCAL_TEST"]) + } } func TestSidecarReadyRequiresFrontendBuild(t *testing.T) { diff --git a/docs/adr/001-single-process-model.md b/docs/adr/001-single-process-model.md index aa4539aa..09fbfcbd 100644 --- a/docs/adr/001-single-process-model.md +++ b/docs/adr/001-single-process-model.md @@ -15,7 +15,7 @@ Everything runs in a single Python process served by uvicorn. There is no extern ## Rationale -- **Self-hosted target:** Operators install with `pip install octop && octop init`. Adding Redis or a process supervisor doubles the ops burden for the primary audience. +- **Self-hosted target:** Operators install with `pip install octop && octop init`. Adding Redis or a process supervisor doubles the ops burden for the primary audience. Historical note (2026-10): that command describes the upstream-era install path. FreeOS 0.1.0 is installed from GitHub Releases or `scripts/install.sh`, not from the PyPI `octop` package. - **Workload profile:** Agent calls are LLM-bound (seconds to minutes of I/O wait). Python's asyncio handles this fan-out efficiently without threads or processes. - **SQLite is sufficient:** Concurrent writes are rare (one writer per agent at a time); WAL mode handles the load. Migrating to Postgres requires only a new `SqlitePool` implementation. - **Restart semantics are simple:** `OctopServer.start()` rebuilds the entire runtime tree from the SQLite file. No external state to reconcile. diff --git a/modules/openxyos/backend/db.ts b/modules/openxyos/backend/db.ts index 4bc0c5a2..ace2776f 100644 --- a/modules/openxyos/backend/db.ts +++ b/modules/openxyos/backend/db.ts @@ -49,6 +49,7 @@ export async function initDatabase(): Promise { role TEXT DEFAULT 'user', tenant_id INTEGER DEFAULT 1, token_version INTEGER NOT NULL DEFAULT 0, + must_change_password INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS companies ( @@ -327,6 +328,11 @@ export async function initDatabase(): Promise { } catch (error: any) { if (!error.message?.includes("duplicate column")) throw error; } + try { + db.run("ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"); + } catch (error: any) { + if (!error.message?.includes("duplicate column")) throw error; + } saveDb(); console.log("[数据库] Schema 初始化完成(含扩展表)"); diff --git a/modules/openxyos/backend/middleware.ts b/modules/openxyos/backend/middleware.ts index 5cb566f1..49727a4d 100644 --- a/modules/openxyos/backend/middleware.ts +++ b/modules/openxyos/backend/middleware.ts @@ -25,6 +25,15 @@ export interface AuthRequest extends Request { user?: AuthUser; } +function passwordChangeAllowed(req: AuthRequest): boolean { + const path = (req.originalUrl || req.url || "").split("?")[0]; + return ( + path === "/api/auth/change-password" + || path === "/api/auth/me" + || path === "/api/auth/revoke" + ); +} + export async function authenticate(req: AuthRequest, res: Response, next: NextFunction) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { @@ -37,6 +46,21 @@ export async function authenticate(req: AuthRequest, res: Response, next: NextFu return res.status(401).json({ success: false, error: result.error || localizedError(req, "登录已过期", "Your session has expired") }); } req.user = result.user; + const gate = dbGet( + "SELECT must_change_password AS flag FROM users WHERE id = ?", + [result.user.id], + ) as { flag?: number } | undefined; + if (Number(gate?.flag) === 1 && !passwordChangeAllowed(req)) { + return res.status(403).json({ + success: false, + code: "PASSWORD_CHANGE_REQUIRED", + error: localizedError( + req, + "请先修改本机引导密码", + "Change the local bootstrap password before continuing", + ), + }); + } runWithRequestContext( { tenantId: result.user.tenant_id, userId: result.user.id }, next, diff --git a/modules/openxyos/backend/routes/auth.ts b/modules/openxyos/backend/routes/auth.ts index 37020015..357098c0 100644 --- a/modules/openxyos/backend/routes/auth.ts +++ b/modules/openxyos/backend/routes/auth.ts @@ -94,11 +94,16 @@ authRoutes.post("/login", async (req, res) => { return res.status(status).json({ success: false, error: authError(req, result.error || "") }); } + const gate = dbGet( + "SELECT must_change_password AS flag FROM users WHERE email = ?", + [email], + ) as { flag?: number } | undefined; res.json({ success: true, data: { user: result.user, tokens: result.tokens, + must_change_password: Number(gate?.flag) === 1, }, }); } catch (err: unknown) { @@ -136,6 +141,41 @@ authRoutes.post("/refresh", async (req, res) => { } }); +// POST /change-password — required before other APIs when the bootstrap flag is set +authRoutes.post("/change-password", authenticate, (req: AuthRequest, res) => { + try { + const current = String(req.body?.current_password || ""); + const next = String(req.body?.new_password || ""); + if (next.length < 12) { + return res.status(400).json({ + success: false, + error: localizedError(req, "新密码至少 12 位", "New password must be at least 12 characters"), + }); + } + const row = dbGet("SELECT password_hash FROM users WHERE id = ?", [req.user!.id]) as + | { password_hash: string } + | undefined; + if (!row || !bcrypt.compareSync(current, row.password_hash)) { + return res.status(401).json({ + success: false, + error: localizedError(req, "当前密码不正确", "Current password is incorrect"), + }); + } + dbRun( + "UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?", + [bcrypt.hashSync(next, 10), req.user!.id], + ); + res.json({ success: true }); + } catch (err: unknown) { + const tid = logError("POST /change-password", err); + res.status(500).json({ + success: false, + error: localizedError(req, "暂时无法修改密码", "Unable to change the password right now"), + traceId: tid, + }); + } +}); + // GET /me — 当前用户信息 authRoutes.get("/me", authenticate, (req: AuthRequest, res) => { const user = dbGet("SELECT id, email, nickname, role, tenant_id FROM users WHERE id = ?", [req.user!.id]); diff --git a/modules/openxyos/backend/seed.ts b/modules/openxyos/backend/seed.ts index 77176a70..638fa01b 100644 --- a/modules/openxyos/backend/seed.ts +++ b/modules/openxyos/backend/seed.ts @@ -1,4 +1,7 @@ import bcrypt from "bcryptjs"; +import { randomBytes } from "crypto"; +import fs from "fs"; +import path from "path"; import { dbAll, dbGet, dbRun, getDb, saveDb } from "./db"; function requiredSeedSecret(name: "SEED_ADMIN_PASSWORD" | "SEED_DEMO_PASSWORD"): string { @@ -13,12 +16,79 @@ function seedAdminEmail(): string { return process.env.SEED_ADMIN_EMAIL?.trim() || "admin@example.com"; } -const LOCAL_BOOTSTRAP_PASSWORD = "openxyos-demo-2026"; - -function localBootstrapPassword(name: "SEED_ADMIN_PASSWORD" | "SEED_DEMO_PASSWORD"): string { +function explicitSeedPassword(name: "SEED_ADMIN_PASSWORD" | "SEED_DEMO_PASSWORD"): string | null { const value = process.env[name]?.trim(); - if (value && value.length >= 12) return value; - return LOCAL_BOOTSTRAP_PASSWORD; + if (!value) return null; + if (value.length < 12) { + throw new Error(`${name} must be at least 12 characters`); + } + return value; +} + +function bootstrapCredentialsPath(): string { + const dbPath = process.env.DATABASE_PATH || path.join(__dirname, "data/xiongyuan.db"); + return path.join(path.dirname(dbPath), "bootstrap-credentials.txt"); +} + +function readBootstrapPassword(file: string): string | null { + if (!fs.existsSync(file)) return null; + const match = /^password=(.+)$/m.exec(fs.readFileSync(file, "utf8")); + const value = match?.[1]?.trim() ?? ""; + return value.length >= 12 ? value : null; +} + +/** Random local password, stored once beside the database. Never logged. */ +function generatedBootstrapPassword(): { password: string; created: boolean } { + const file = bootstrapCredentialsPath(); + const existing = readBootstrapPassword(file); + if (existing) return { password: existing, created: false }; + const password = randomBytes(24).toString("base64url"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + [ + "# Local bootstrap credentials for this machine only.", + "# Sign in once, then change the password. Do not commit this file.", + `password=${password}`, + "", + ].join("\n"), + { encoding: "utf8", mode: 0o600 }, + ); + try { + fs.chmodSync(file, 0o600); + } catch { + // Windows ACLs are not POSIX modes; the file still stays under the user profile. + } + console.log(`[seed] local bootstrap credentials written to ${file}`); + return { password, created: true }; +} + +function upsertBootstrapUser(opts: { + email: string; + password: string; + nickname: string; + role: string; + tenantId: number; + mustChange: boolean; + rotate: boolean; +}): void { + const hash = bcrypt.hashSync(opts.password, 10); + const existing = dbGet("SELECT id FROM users WHERE email = ?", [opts.email]); + if (!existing) { + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, ?, ?, ?)", + [opts.email, hash, opts.nickname, opts.role, opts.tenantId, opts.mustChange ? 1 : 0], + ); + console.log(`[seed] local bootstrap account ${opts.email}`); + return; + } + if (opts.rotate) { + dbRun( + "UPDATE users SET password_hash = ?, must_change_password = 1 WHERE email = ?", + [hash, opts.email], + ); + console.log(`[seed] rotated bootstrap password for ${opts.email}; change it on next sign-in`); + } } /** First-run local accounts when demo seed is off (FreeOS desktop sidecar). */ @@ -35,30 +105,42 @@ export function ensureLocalBootstrap(): void { "UPDATE tenants SET status = 'active' WHERE id IN (1, 2) AND status NOT IN ('active', 'trial')", ); - const demoHash = bcrypt.hashSync(localBootstrapPassword("SEED_DEMO_PASSWORD"), 10); - const adminHash = bcrypt.hashSync(localBootstrapPassword("SEED_ADMIN_PASSWORD"), 10); + const demoExplicit = explicitSeedPassword("SEED_DEMO_PASSWORD"); + const adminExplicit = explicitSeedPassword("SEED_ADMIN_PASSWORD"); + const generated = demoExplicit && adminExplicit ? null : generatedBootstrapPassword(); + const demoPassword = demoExplicit ?? generated!.password; + const adminPassword = adminExplicit ?? generated!.password; + const rotateGenerated = Boolean(generated?.created); const adminEmail = seedAdminEmail(); - if (!dbGet("SELECT id FROM users WHERE email = ?", ["demo@demo.com"])) { - dbRun( - "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'admin', 2)", - ["demo@demo.com", demoHash, "管理员"], - ); - console.log("[seed] local bootstrap admin demo@demo.com"); - } - if (!dbGet("SELECT id FROM users WHERE email = ?", ["user@demo.com"])) { - dbRun( - "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'user', 2)", - ["user@demo.com", demoHash, "同事"], - ); - console.log("[seed] local bootstrap user user@demo.com"); - } - if (adminEmail !== "demo@demo.com" && !dbGet("SELECT id FROM users WHERE email = ?", [adminEmail])) { - dbRun( - "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'super_admin', 1)", - [adminEmail, adminHash, "超级管理员"], - ); - console.log(`[seed] local bootstrap super admin ${adminEmail}`); + upsertBootstrapUser({ + email: "demo@demo.com", + password: demoPassword, + nickname: "管理员", + role: "admin", + tenantId: 2, + mustChange: demoExplicit === null, + rotate: demoExplicit === null && rotateGenerated, + }); + upsertBootstrapUser({ + email: "user@demo.com", + password: demoPassword, + nickname: "同事", + role: "user", + tenantId: 2, + mustChange: demoExplicit === null, + rotate: demoExplicit === null && rotateGenerated, + }); + if (adminEmail !== "demo@demo.com") { + upsertBootstrapUser({ + email: adminEmail, + password: adminPassword, + nickname: "超级管理员", + role: "super_admin", + tenantId: 1, + mustChange: adminExplicit === null, + rotate: adminExplicit === null && rotateGenerated, + }); } } @@ -79,8 +161,7 @@ export function seedDatabase() { return; } if (process.env.SEED_DEMO_DATA !== "true") { - console.log("[seed] Demo data is disabled. Seeding local first-login accounts only."); - ensureLocalBootstrap(); + console.log("[seed] Demo data is disabled. Local accounts are seeded only when FREEOS_ORG_LOCAL_TEST=1."); return; } diff --git a/modules/openxyos/backend/server.ts b/modules/openxyos/backend/server.ts index c8b9c1c5..39052c49 100644 --- a/modules/openxyos/backend/server.ts +++ b/modules/openxyos/backend/server.ts @@ -282,7 +282,7 @@ app.get("/admin/database", (req, res) => { setupWebSocket(server); - server.listen(PORT, process.env.HOST || '0.0.0.0', () => { + server.listen(PORT, process.env.HOST || '127.0.0.1', () => { console.log(`\n 🌐 openXYOS Community — Human + Agent OS`); console.log(` ─────────────────────────────────────`); console.log(` Server: http://localhost:${PORT}`); diff --git a/modules/openxyos/frontend/src/pages/AuthPage.tsx b/modules/openxyos/frontend/src/pages/AuthPage.tsx index 15e27d01..7700352e 100644 --- a/modules/openxyos/frontend/src/pages/AuthPage.tsx +++ b/modules/openxyos/frontend/src/pages/AuthPage.tsx @@ -8,7 +8,7 @@ const R = "[border-radius:1.5px]"; export default function AuthPage() { const navigate = useNavigate(); - const { login, register } = useAuthStore(); + const { login, register, changePassword } = useAuthStore(); const { isEnglish } = useLocale(); const tx = (zh: string, en: string) => (isEnglish ? en : zh); const [tab, setTab] = useState<"login" | "register">("login"); @@ -18,6 +18,9 @@ export default function AuthPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [agreed, setAgreed] = useState(false); + const [mustChange, setMustChange] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [nextPassword, setNextPassword] = useState(""); const enterAfterAuthentication = async () => { try { @@ -34,14 +37,22 @@ export default function AuthPage() { } }; - const enterTestAccount = async () => { + const handlePasswordChange = async (e: React.FormEvent) => { + e.preventDefault(); setLoading(true); setError(""); try { - await login("demo@demo.com", "openxyos-demo-2026"); + if (nextPassword.length < 12) { + setError(tx("新密码至少 12 位", "New password must be at least 12 characters.")); + return; + } + await changePassword(currentPassword, nextPassword); + setMustChange(false); + setCurrentPassword(""); + setNextPassword(""); await enterAfterAuthentication(); } catch (err: any) { - setError(err.message || tx("测试账号暂时不可用", "The test account is unavailable.")); + setError(err.message || tx("暂时无法修改密码", "Unable to change the password right now.")); } finally { setLoading(false); } @@ -53,7 +64,13 @@ export default function AuthPage() { setError(""); try { if (tab === "login") { - await login(email, password); + const needsChange = await login(email, password); + if (needsChange) { + setMustChange(true); + setCurrentPassword(password); + setPassword(""); + return; + } } else { if (!agreed) { setError( @@ -186,19 +203,11 @@ export default function AuthPage() {
{tx( - "试用、布置这里的流程;钥匙只开这扇门。", - "Try and arrange the workflows here. This key opens only this door.", + "本机引导口令只写在数据目录的 bootstrap-credentials.txt,登录后必须修改。页面不再内置演示口令。", + "The local bootstrap password is written only to bootstrap-credentials.txt in the data directory. Change it after signing in. This page does not embed a demo password.", )} -

@@ -213,6 +222,39 @@ export default function AuthPage() { ))}
+ {mustChange ? ( +
void handlePasswordChange(e)}> +

+ {tx( + "这是本机引导账号。请先设置一个新密码,然后才能继续。", + "This is a local bootstrap account. Set a new password before continuing.", + )} +

+
+ + setNextPassword(e.target.value)} + placeholder={tx("至少 12 位", "At least 12 characters")} + required + autoComplete="new-password" + className={`w-full px-3.5 py-2.5 border-[1.5px] border-border ${R} text-sm bg-bg-card text-text outline-none transition-all focus:border-primary`} + /> +
+ {error &&

{error}

} + +
+ ) : (
diff --git a/modules/openxyos/frontend/src/pages/OpenHomePage.tsx b/modules/openxyos/frontend/src/pages/OpenHomePage.tsx index f160ae10..42d9021e 100644 --- a/modules/openxyos/frontend/src/pages/OpenHomePage.tsx +++ b/modules/openxyos/frontend/src/pages/OpenHomePage.tsx @@ -1,8 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ArrowRight, Blocks, Bot, Braces, Building2, Check, ChevronDown, CircleDot, Code2, Copy, Eye, EyeOff, GitBranch, Github, KeyRound, Layers3, LockKeyhole, Menu, MessageSquareMore, Network, Play, ShieldCheck, Sparkles, Terminal, Users, X } from "lucide-react"; -import { useAuthStore } from "../stores/auth"; -import { authFetch } from "../api/authFetch"; import { EXPERIENCE_MODELS, type ExperienceModelId } from "../llm-providers"; import "../openxyos.css"; import AgentJourneyDemo from "../components/AgentJourneyDemo"; @@ -385,37 +383,19 @@ function HeroRippleField() { export default function OpenHomePage() { const { isEnglish } = useLocale(); const tx = (zh: string, en: string) => isEnglish ? en : zh; - const navigate = useNavigate(), { login } = useAuthStore(); + const navigate = useNavigate(); const [demoOpen, setDemoOpen] = useState(false), [demoPhase, setDemoPhase] = useState<"model" | "account">("model"), [loginState, setLoginState] = useState<"idle" | "admin" | "user">("idle"), [loginError, setLoginError] = useState(""), [copied, setCopied] = useState(false), [navOpen, setNavOpen] = useState(false); const [providerId, setProviderId] = useState("ollama"), [apiKey, setApiKey] = useState(""), [showKey, setShowKey] = useState(false); const openDemo = (phase: "model" | "account") => { setDemoPhase(phase); setLoginError(""); setLoginState("idle"); setDemoOpen(true); }; - const enterDemo = async (kind: "admin" | "user") => { - setLoginState(kind); setLoginError(""); - try { await login(kind === "admin" ? "demo@demo.com" : "user@demo.com", "openxyos-demo-2026"); navigate("/app"); } - catch (error) { setLoginError(error instanceof Error ? error.message : tx("演示环境暂时不可用,请使用登录页进入。", "The demo is temporarily unavailable. Please use the sign-in page.")); setLoginState("idle"); } + const enterDemo = (_kind: "admin" | "user") => { + setDemoOpen(false); + navigate("/auth"); }; - const saveModelAndEnter = async () => { - const selected = EXPERIENCE_MODELS.find(model => model.id === providerId); - const isLocal = Boolean(selected && "local" in selected && selected.local); - if (!isLocal && (apiKey.trim().length < 8 || /\s/.test(apiKey.trim()))) { setLoginError(tx("请输入有效的 API Key(不能包含空格)", "Enter a valid API key without spaces.")); return; } - setLoginState("admin"); setLoginError(""); - try { - await login("demo@demo.com", "openxyos-demo-2026"); - const response = await authFetch("/api/settings/ai/onboarding", { - method: "PUT", - body: JSON.stringify({ providerId, apiKey: isLocal ? (apiKey.trim() || "ollama") : apiKey.trim() }), - }); - const body = await response.text(); - const result = body ? JSON.parse(body) : null; - if (!response.ok || !result?.success) throw new Error(result?.error || tx("模型配置保存失败", "Unable to save model configuration.")); - setApiKey(""); - navigate("/app"); - } catch (error) { - setLoginError(error instanceof Error ? error.message : tx("模型配置保存失败", "Unable to save model configuration.")); - setLoginState("idle"); - } + const saveModelAndEnter = () => { + setDemoOpen(false); + navigate("/auth"); }; const copyInstall = async () => { await navigator.clipboard.writeText("npm ci && npm run dev"); setCopied(true); window.setTimeout(() => setCopied(false), 1600); }; const closeNav = () => setNavOpen(false); @@ -465,6 +445,6 @@ export default function OpenHomePage() {
BUILD WITH US

{tx("不只使用系统,一起定义人机组织的未来", "Do more than use the system—define the future of human–AI organizations")}

{tx("欢迎从组织模型、智能体协作、治理策略、模块生态和工程质量开始贡献。", "Contribute through organization models, agent collaboration, governance policy, module ecosystems, and engineering quality.")}

{GITHUB_URL ? {tx("查看 Issues", "View issues")} : {tx("阅读源码边界", "Read source boundary")}}
openXYOS

Open-source operating system for human–agent organizations.

Apache License 2.0 · Built in the open
- {demoOpen &&
setDemoOpen(false)}>
e.stopPropagation()}>{demoPhase === "model" ? <>

{tx("接入大模型,体验完整智能能力", "Connect a model to experience full intelligence")}

{tx("优先使用本机 Ollama;云厂商需要你自己的 API Key,安装包不会预填密钥。密钥保存到当前本地演示租户,页面不会回显原文;之后可在“系统设置 → AI 大模型”中更换。", "Prefer local Ollama first. Cloud providers need your own API key — installers never prefill one. The key is stored only in the current demo tenant and is never shown back in plain text; change it later in System Settings → AI Models.")}

{EXPERIENCE_MODELS.map(model => )}
{tx("Key 仅发送到当前 openXYOS 后端,不写入浏览器日志,也不会再次明文返回。", "The key is sent only to the current openXYOS backend, never logged in the browser, and never returned in plain text.")}
{loginState !== "idle" &&

{tx("正在登录演示租户并保存模型配置…", "Signing in to the demo tenant and saving model configuration…")}

}{loginError &&

{loginError}

}
: <>

{tx("选择测试视角", "Choose a demo perspective")}

{tx("使用本地演示租户进入真实系统。管理员可体验模块开关和名称编辑,普通员工只看到已授权能力。", "Enter the live system with the local demo tenant. Administrators can manage modules and labels; users see only authorized capabilities.")}

{loginState !== "idle" &&

{tx("正在连接演示环境…", "Connecting to the demo environment…")}

}{loginError &&

{loginError}

}}
} + {demoOpen &&
setDemoOpen(false)}>
e.stopPropagation()}>{demoPhase === "model" ? <>

{tx("接入大模型,体验完整智能能力", "Connect a model to experience full intelligence")}

{tx("优先使用本机 Ollama;云厂商需要你自己的 API Key,安装包不会预填密钥。密钥保存到当前本地演示租户,页面不会回显原文;之后可在“系统设置 → AI 大模型”中更换。", "Prefer local Ollama first. Cloud providers need your own API key — installers never prefill one. The key is stored only in the current demo tenant and is never shown back in plain text; change it later in System Settings → AI Models.")}

{EXPERIENCE_MODELS.map(model => )}
{tx("Key 仅发送到当前 openXYOS 后端,不写入浏览器日志,也不会再次明文返回。", "The key is sent only to the current openXYOS backend, never logged in the browser, and never returned in plain text.")}
{loginState !== "idle" &&

{tx("正在登录演示租户并保存模型配置…", "Signing in to the demo tenant and saving model configuration…")}

}{loginError &&

{loginError}

}
: <>

{tx("选择测试视角", "Choose a demo perspective")}

{tx("使用本地演示租户进入真实系统。管理员可体验模块开关和名称编辑,普通员工只看到已授权能力。", "Enter the live system with the local demo tenant. Administrators can manage modules and labels; users see only authorized capabilities.")}

{loginState !== "idle" &&

{tx("正在连接演示环境…", "Connecting to the demo environment…")}

}{loginError &&

{loginError}

}}
} ; } diff --git a/modules/openxyos/frontend/src/stores/auth.ts b/modules/openxyos/frontend/src/stores/auth.ts index a447b041..8c2cff83 100644 --- a/modules/openxyos/frontend/src/stores/auth.ts +++ b/modules/openxyos/frontend/src/stores/auth.ts @@ -13,7 +13,8 @@ interface AuthState { user: User | null; token: string | null; loading: boolean; - login: (email: string, password: string) => Promise; + login: (email: string, password: string) => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; register: ( email: string, password: string, @@ -47,16 +48,41 @@ export const useAuthStore = create((set) => ({ localStorage.setItem("token", token); localStorage.setItem("user", JSON.stringify(data.data.user)); set({ user: data.data.user, token }); - // 记录登录事件 - authFetch("/api/admin/visitor-log", { + const mustChange = Boolean(data.data?.must_change_password); + if (!mustChange) { + // 记录登录事件 + authFetch("/api/admin/visitor-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_type: "login", + user_id: data.data.user.id, + tenant_id: data.data.user.tenant_id, + }), + }).catch(() => {}); + } + return mustChange; + }, + + changePassword: async (currentPassword, newPassword) => { + const res = await authFetch("/api/auth/change-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - event_type: "login", - user_id: data.data.user.id, - tenant_id: data.data.user.tenant_id, + current_password: currentPassword, + new_password: newPassword, }), - }).catch(() => {}); + }); + const body = await res.text(); + let data: any = {}; + if (body) { + try { + data = JSON.parse(body); + } catch { + throw new Error("后端返回了无法识别的响应"); + } + } + if (!res.ok || !data.success) throw new Error(data.error || "修改密码失败"); }, register: async (email, password, nickname) => { diff --git a/modules/openxyos/package-lock.json b/modules/openxyos/package-lock.json index af9c00c1..19d726a1 100644 --- a/modules/openxyos/package-lock.json +++ b/modules/openxyos/package-lock.json @@ -75,7 +75,7 @@ "zustand": "^5.0.0" }, "engines": { - "node": ">=20.19" + "node": ">=22.12" } }, "node_modules/@antfu/install-pkg": { @@ -191,7 +191,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1849,7 +1848,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1898,7 +1896,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1920,7 +1917,6 @@ "resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1957,10 +1953,10 @@ "react": ">=16.8.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -4247,6 +4243,27 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "dev": true, @@ -4772,7 +4789,6 @@ "resolved": "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4911,7 +4927,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5031,7 +5046,6 @@ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", @@ -5342,7 +5356,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5378,7 +5391,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5871,7 +5883,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", @@ -6454,7 +6465,6 @@ "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6912,7 +6922,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7710,7 +7719,6 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11083,7 +11091,6 @@ "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz", "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -11443,7 +11450,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11453,7 +11459,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12712,7 +12717,6 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -12884,7 +12888,6 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -13027,7 +13030,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13326,7 +13328,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 84671b5b..6f9d77ba 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -106,7 +106,7 @@ }, "description": "Open-source operating system for governed human-agent organizations", "engines": { - "node": ">=20.19" + "node": ">=22.12" }, "overrides": { "qs": "6.16.0", diff --git a/scripts/release_prerelease.py b/scripts/release_prerelease.py new file mode 100644 index 00000000..3a93ed5f --- /dev/null +++ b/scripts/release_prerelease.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Print whether a FreeOS tag must be a GitHub prerelease. + +After a leading ``v`` is stripped, PEP 440 pre-releases match +``(a|b|rc)[0-9]+$`` or ``.dev[0-9]+$``. ``v0.1.0rc1`` is a prerelease; +``v0.1.0`` is not. Output is a GitHub Actions assignment: ``prerelease=true``. +""" + +from __future__ import annotations + +import re +import sys + +_PRE = re.compile(r"(?:(?:a|b|rc)[0-9]+|\.dev[0-9]+)$") + + +def is_release_prerelease(tag: str) -> bool: + version = tag[1:] if tag.startswith("v") else tag + return _PRE.search(version) is not None + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("usage: release_prerelease.py ", file=sys.stderr) + return 2 + flag = "true" if is_release_prerelease(argv[1]) else "false" + print(f"prerelease={flag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 4556b33a..1e0bfe91 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -374,6 +374,7 @@ def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict { "NODE_ENV": "production", "PORT": port, + "HOST": "127.0.0.1", "DB_DIALECT": "sqlite", "DATABASE_PATH": str(sidecar_data_dir(home) / "xiongyuan.db"), "AIR_GAP_MODE": "true", diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index 01d16323..52d25d80 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -159,6 +159,8 @@ def test_start_sidecar_cors_origin_is_parseorigins_safe() -> None: assert origins assert "*" not in origins assert "http://127.0.0.1:3780" in origins + assert "$env:HOST = '127.0.0.1'" in text + assert "$env:FREEOS_ORG_LOCAL_TEST = '1'" in text assert "http://localhost:3780" in origins for origin in origins: parsed = urlparse(origin) diff --git a/tests/unit/test_release_prerelease.py b/tests/unit/test_release_prerelease.py new file mode 100644 index 00000000..de64da09 --- /dev/null +++ b/tests/unit/test_release_prerelease.py @@ -0,0 +1,27 @@ +"""GitHub Release prerelease classification for FreeOS tags.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "release_prerelease.py" +_spec = importlib.util.spec_from_file_location("release_prerelease", _SCRIPT) +assert _spec is not None and _spec.loader is not None +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + + +def test_rc_alpha_beta_and_dev_are_prereleases() -> None: + assert _mod.is_release_prerelease("v0.1.0rc1") is True + assert _mod.is_release_prerelease("0.1.0rc1") is True + assert _mod.is_release_prerelease("v0.1.0a1") is True + assert _mod.is_release_prerelease("v0.1.0b2") is True + assert _mod.is_release_prerelease("v0.1.0.dev1") is True + assert _mod.is_release_prerelease("0.1.0.dev3") is True + + +def test_production_tags_are_not_prereleases() -> None: + assert _mod.is_release_prerelease("v0.1.0") is False + assert _mod.is_release_prerelease("0.0.10") is False + assert _mod.is_release_prerelease("v0.1.0-rc.1") is False diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py index 6a170536..b15fe657 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -305,6 +305,7 @@ def test_sidecar_launch_env_writes_secrets(tmp_path: Path) -> None: assert env["FREEOS_INGEST_TOKEN"] assert env["FREEOS_ORG_INTEGRATED"] == "1" assert env["FREEOS_ORG_LOCAL_TEST"] == "1" + assert env["HOST"] == "127.0.0.1" def test_sidecar_launch_env_merges_existing_cors( From 17bc806d7fe63eddf7d370b15809a6adde771a63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 22:00:13 +0000 Subject: [PATCH 07/39] fix(release): brand new FnOS packages and clear npm audit Name new FnOS artifacts FreeOS-fnos-*.fpk in the workflow, build script, release links, and user docs. Older Release assets may still be named Octop-fnos-*. Remove the expired WeCom QR from the user guide and README_CN. Readers go to GitHub Discussions or the existing Discord link. Bump vite to 6.4.5, vitest to 4.1.12, and follow-redirects to 1.16.1 so Desktop Package npm audit exits 0. Co-authored-by: haoboy007-sudo --- .github/workflows/fnos-build-fpk.yml | 12 +- README_CN.md | 10 +- dashboard/package-lock.json | 154 ++++++++++------------ dashboard/package.json | 9 +- docs/assets/qrcode.png | Bin 4766 -> 0 bytes docs/user-guide.md | 9 +- fnos/README.md | 16 +-- modules/openxyos/package-lock.json | 14 +- modules/openxyos/package.json | 4 +- scripts/build-fpk.sh | 16 +-- scripts/release_download_links.py | 4 +- tests/unit/test_release_download_links.py | 4 +- 12 files changed, 115 insertions(+), 137 deletions(-) delete mode 100644 docs/assets/qrcode.png diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index f8c06e06..7b14e4e1 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -25,7 +25,7 @@ permissions: packages: read concurrency: - group: build-octop-fnos + group: build-freeos-fnos cancel-in-progress: false jobs: @@ -99,14 +99,14 @@ jobs: - name: Build Docker FPK env: - FPK_NAME_PREFIX: Octop-fnos + FPK_NAME_PREFIX: FreeOS-fnos run: bash scripts/build-fpk.sh docker - name: Upload docker fpk artifact uses: actions/upload-artifact@v4 with: name: docker-fpk - path: dist/Octop-fnos-docker-*.fpk + path: dist/FreeOS-fnos-docker-*.fpk if-no-files-found: error native: @@ -203,18 +203,18 @@ jobs: - name: Build native FPK id: build_native env: - FPK_NAME_PREFIX: Octop-fnos + FPK_NAME_PREFIX: FreeOS-fnos run: bash scripts/build-fpk.sh native - name: Show native fpk size - run: ls -lh dist/Octop-fnos-native-*.fpk + run: ls -lh dist/FreeOS-fnos-native-*.fpk - name: Upload native fpk artifact id: upload_native uses: actions/upload-artifact@v4 with: name: native-fpk - path: dist/Octop-fnos-native-*.fpk + path: dist/FreeOS-fnos-native-*.fpk if-no-files-found: error - name: Upload native install log (for debugging) diff --git a/README_CN.md b/README_CN.md index 93505529..a708ad06 100644 --- a/README_CN.md +++ b/README_CN.md @@ -243,7 +243,7 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install | Windows | `FreeOS-desktop-windows-amd64-.exe`(64 位)/ `FreeOS-desktop-windows-arm64-.exe`(ARM64)— NSIS 安装程序,单进程 FreeOS + 宿主内 Organization;不捆绑也不自动拉起 openXYOS Node | | macOS | `FreeOS-desktop-darwin-arm64-.dmg`(Apple 芯片)/ `FreeOS-desktop-darwin-amd64-.dmg`(Intel) | | Linux | `FreeOS-desktop-linux-amd64-.tar.gz` / `FreeOS-desktop-linux-arm64-.tar.gz` | -| 飞牛 NAS(FnOS) | `Octop-fnos-docker-.fpk`(依赖 Docker)/ `Octop-fnos-native-.fpk`(无需 Docker)— 通过应用中心安装 | +| 飞牛 NAS(FnOS) | `FreeOS-fnos-docker-.fpk`(依赖 Docker)/ `FreeOS-fnos-native-.fpk`(无需 Docker)— 通过应用中心安装。更早的 Release 资产可能仍叫 `Octop-fnos-*` | 桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。独立商业组织站见 [docs/org-export.md](docs/org-export.md)(`freeos org export-standalone`,默认导出完整 openXYOS 源码树),不是默认安装器里的第二套 Node。 @@ -545,13 +545,7 @@ cd dashboard && npx tsc --noEmit ### 💬 客户企业微信群 -如需加入客户企业微信服务群,请扫码: - -

- 客户企业微信服务群二维码 -

- -> 请扫码进入工作群,如有任何疑问或需求,请直接联系群管理员对接处理。 +客户企业微信群二维码已失效(有效期至 2026-08-03),本文不再提供该图。请到 [GitHub Discussions](https://github.com/XYAIStudio/FreeOS/discussions) 讨论,或使用页首 [Discord](https://discord.gg/jPas5J8Ua) 徽章加入社区。 ### 📄 许可证 diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 9ef4a83a..b53fed4f 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -50,7 +50,7 @@ "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.4.1", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^4.1.12", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -62,9 +62,9 @@ "rollup-plugin-visualizer": "^5.14.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5", + "vite": "^6.4.5", "vite-plugin-pwa": "^0.21.2", - "vitest": "^4.1.11" + "vitest": "^4.1.12" } }, "node_modules/@adobe/css-tools": { @@ -253,7 +253,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1929,7 +1928,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1953,7 +1951,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3915,7 +3912,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4353,7 +4351,6 @@ "resolved": "https://mirrors.tencent.com/npm/@types/react/-/react-18.3.30.tgz", "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4364,7 +4361,6 @@ "resolved": "https://mirrors.tencent.com/npm/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4448,7 +4444,6 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -4733,15 +4728,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz", + "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4755,8 +4749,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "4.1.12", + "vitest": "4.1.12" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4765,16 +4759,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4783,13 +4777,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4820,9 +4814,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -4833,13 +4827,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -4847,14 +4841,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4863,9 +4857,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -4873,13 +4867,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4909,8 +4903,7 @@ "version": "5.5.0", "resolved": "https://mirrors.tencent.com/npm/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.16.0", @@ -4918,7 +4911,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5358,7 +5350,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", @@ -5805,7 +5796,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -6203,7 +6193,6 @@ "resolved": "https://mirrors.tencent.com/npm/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -6565,7 +6554,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.16", @@ -6890,7 +6880,6 @@ "resolved": "https://mirrors.tencent.com/npm/eslint/-/eslint-9.39.4.tgz", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7984,7 +7973,6 @@ "url": "https://www.locize.com" } ], - "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -9209,6 +9197,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10332,6 +10321,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" @@ -10341,6 +10331,7 @@ "version": "14.0.0", "resolved": "https://mirrors.tencent.com/npm/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10721,7 +10712,6 @@ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=20.16.0 || >=22.3.0" }, @@ -10867,6 +10857,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10882,6 +10873,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10894,7 +10886,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prismjs": { "version": "1.30.0", @@ -11563,7 +11556,6 @@ "resolved": "https://mirrors.tencent.com/npm/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11576,7 +11568,6 @@ "resolved": "https://mirrors.tencent.com/npm/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11706,7 +11697,6 @@ "resolved": "https://mirrors.tencent.com/npm/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11864,8 +11854,7 @@ "version": "5.0.1", "resolved": "https://mirrors.tencent.com/npm/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12166,7 +12155,6 @@ "resolved": "https://mirrors.tencent.com/npm/rollup/-/rollup-4.61.0.tgz", "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -13245,7 +13233,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13636,12 +13623,11 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://mirrors.tencent.com/npm/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -13743,20 +13729,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -13784,12 +13769,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -14145,7 +14130,6 @@ "resolved": "https://mirrors.tencent.com/npm/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/dashboard/package.json b/dashboard/package.json index e0e6d198..9c5cf999 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -54,7 +54,8 @@ "serialize-javascript": "^7.0.3", "@rollup/plugin-terser": "^1.0.0", "katex": "^0.19.0", - "dompurify": "^3.4.16" + "dompurify": "^3.4.16", + "vite": "^6.4.5" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -68,7 +69,7 @@ "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.4.1", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^4.1.12", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -80,8 +81,8 @@ "rollup-plugin-visualizer": "^5.14.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5", + "vite": "^6.4.5", "vite-plugin-pwa": "^0.21.2", - "vitest": "^4.1.11" + "vitest": "^4.1.12" } } diff --git a/docs/assets/qrcode.png b/docs/assets/qrcode.png deleted file mode 100644 index 6703b9d7870f9c2167a954ecbb786b1e59d99ad3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4766 zcmZ{oc{H2b*T>UVQA1U4D`}MuI>waT8j5PGDr#;eC~AIGCG&E(?VOr zKqtSUMHxH1;OUn3H1%2cG)WOl{H&HfvC1NWX33>aDW86FI;QZgOZr-{?_lOb-$&jm zoeTB8P$tT@;q++i4iH!#>bjBLP8drBty$$_33mSFd;-E23T9I-1M`bMb_Q+RkkzZXp_B-^ci$Nx9JK=^ zD(rmhuR)9*o7m+Y6p+sE+=FfI851NU_*Nu{-dq7Y#ZuK?rf_TiO!2=K|Ckr?XPH^B zweEVSpgAlfu=(%E)i^w%fIj1m`eK2HIjcMAp@GFG82;G~{@~p|bTM2qtg_oAJhgZNtZGxn=X z4kL@3dpU@-8ASdI#%t!q54`!$&DiWD*`*w7>&j3q*MC>=&v=T$-Gcb?YUv%C&c{I@ z2dr=tVR%XhW<~y`mXe+h-}Yu(>`BdL;3b{7mSr8YwO1o#41dt8X@0q}f4R7q7l7j- z4o(l3aJBeJs_QjbYXv|>wtJ0Djq(xb_kDz*-2XcXfUs(Q_7uxT+p1p^<}{ZTVMrl$ z)kkV#Dff?kyMwv&k_T?C3M&ci%I};CqXc3^(B@prwh4T3j1-$_GLg9)zN`CtqlCVe zFv@)&QDGS46;DvDRr$(dKe>s8brivoHYF<>^-YHfgV|T4)BGB*LJr5K0ML~YJ?Oy+ zJd=d5V@*oF#ff0M>s2+=cn@b}k4V5EF_ZJ?Lp2H@{ZX&JAR)K2N0gr@{_U_Re3t=& zvP1k`&d6p^7`%aQRG{h6)M+jRo1~U*$FWF zDWw04GXtcB9qo)z`2=*=qha8Wf;>K+u|XQ!%-ETQ__E!dTD%mDAZzi81`z9Tl@CC7 zTR)DG8M20Ydz=ibnh~?$ek7H><#hrn7`*E4{ht@`R^cRyR%Bvc);=%RMWXs#(NyS>TbW`9(|17lKO}IysLcwMM|IZ408)!^DJ9 z)1+Ia3>P`#0denyC64}x&(<&2jAh-CC@G=llvCKw*p`fl7e5`;_hoQ>04^5sKn@V>y#9<4Xi~J=OHZ->IE5qU zqmF~R<+*Fi)$)6&-G^tDO~bVLtqgmd{{k3ijK@?3$A>=h9xqbiK7V7l4lZ*D?PT`y zkF8g4N#}`|p6c?Na@R@t0H;^BpLV zQ7=)@PMJn@EMHQNAWnSIK|fnAb#`MU#zYe%A6{0|Be^S=`%tngnR~E;2GvdsV0`NAQ^_(P~;USU8G z+Jtr3$7ovh7KACc&Cjt8W+8kfsGDlDgxnFcGvXEuloQ3TBC^7-r zaR^&D))^3c@&#LVA*R5Y~saP`?jj|H~C$mp#$zMqL0 zgI+MlV>GWp>TV5ajPG2@#CS{>Isq3e5;-f63(E&)dUVrCrR#iy**>EY|GCLopH0li zMhT&Ow7%eQ+WPjny3APDU!Z^Ne!KSE9BrmkgcSLpp6@Hiy?cdSR#)B;k?1T@lb0_e z!Eqn)G94*JE2?_m1j^*l97?1_x~t6Ew6}Egq}CJR*C80qPZMLczwBn>EoZ|VHiqyb z@q}gsD8lU`C4$yiV~%dQYS&wmwrn-n6I~u|KW5cjqd3tw(kkw#vG?^6u5^;3a5p~( znjn2G#z5*)`CLj+WLv6*$n{K`xLq%vT-U?tdSU%c?tj`ULY;@Kvs@**drh$qo~%!Q zQdx*0&sAG&Q^yszuuGhwQ@X_>l_$N+TPqwhD{mqz>~QIeM>p-a=~*5@miGJ?1qsn- zJT%j*Yf-Nt?%|Equ>>Md*sNqxUUw~=VKC*y={zMzl~auIupfx`zPiAA z_79FIN^G4T;qXzO`O!!To^=FAlN%pA10FX)>JUSXxQiu;gxbZ;`+>pe&}_OsS(Q&L zf<}#wbnN@Uy=rEYfwoODPQJsCb|~&C#wTd!rmK}JV{r}IE5H9r2Uggta`dqUI6OS5e4yc_OP`0@b}!*MG2iT$i1rJ2Gz73ddZrM_KxDtId5#ck6qT zD_}sc64K261w~4c@>C+w?K$-z86jI~Oyy%7RA*x7vMi;DoV2FGIcjsXH=JCBRD0sy z#r4w;yi+j0r;M6j8T?UtLdt;$Kg>{OmiQ~*XkSQhURk0MtNelbqpo2t63Gb|&B4ZQ zN$btA+WxP&bv2-S`qX;hW@)LqAJs)}2_#<9s|hc1uAXfG(S*x72UXpNGGwHH>>M-B zp0cMERwAe-Jz z#vDK6VOm~#!KbuuvG?_r%XFv@CrSIoDPOuI9nzHBC<3~Wwo}P*ZV^u|TB@(pz8${) zsg@mAK4B%R3jScA{Ue39`%-S~PMprx8#U>7cPH~xRFbC`G?hC$(1RM5x^tI&T(=`i ztSXuj?f;+3tPGRM6DT1Wf#{5?pBi!x)sifDN~J6I`l09At+F? zz^s3tL_G1*2w%~LLPJ~}Z$|Hv+Ygd0%36@TrvEK-{a43=9P-!;z67@hFLoLa;hXA^6ZhND%`%MOKYB&&Te`Zj z+B!XRo*DW|TNswW2a?p^2V7MyKOD6~l_}lbQ7<)i1XB^nV$YJcZ_{6I*EqSr45r3p zzeAxb6S9u9#=L*+i=ZHS-y-7; z?og$>-;vgptWL8Jpg(y_&90j}d^HRv89m|r zzUanebtdHcAloj{>W)d71zyQ4A`)%BNunXTn&W@DGx}||Y8ylZ&s$|d11||A+$pY# z-}d9L)WCJ>4RxxX$NJl=Sk>NjJ247-=;- zuqvxC&9_;T?OXlJ(5v;12$|09Q>6^Hd+qWgBsQV1W@xCJ)Hn5^Mn<7FX~>vE{fPls9Mmti_4y(m*|&$6+)$4erDN<)^ewht1aF)^fe)3QfB= z8;6F|cK#dm6KWkY;ZEM{g_p&~>k&=Py=q5?K9TEMM~J&GehgN2_8umr@@A059aWtq zJTnqHpk?i}*_v(KQbZ+}#>FA>Go}{Q^fIJB*$3E7J?vG|>KgN9CiQFv2piHn6~QM~1&6+xKC}iCllxM1Mj%IlS9_GhoI> z-i;M-yLeatY5!(0*o%3&@jw-BI=o>Xw6K>3>j$HS(|EttDtOd3o+-AbtVX%2AtYdq z=_d(mzHK3XA#*%Mi%L1iq3MMj-s3_`!XJvyirFOIrCzN{?i;6YU&Bgc)L=&yw5%u2 zZC+al@a%8M|6gQBP3TxP%z}85J)78x<5l@H 二维码有效期至 **2026-08-03**,过期后请联系管理员更新。 +- 客户企业微信群二维码已于 **2026-08-03** 失效,指南不再附带该图。 +- 请改用 [GitHub Discussions](https://github.com/XYAIStudio/FreeOS/discussions),或加入 [Discord](https://discord.gg/jPas5J8Ua)。 --- @@ -497,4 +494,4 @@ Octop 支持两个方向的 ACP 集成: | 图 5.6 | 5.8 设置 | `use-06-settings.png` | ✅ 已就位 | 应用设置页面 | | 图 5.7 | 5.9 远程桌面 | `use-07-remote-desktop.png` | ✅ 已就位 | 远程桌面连接页 | | 图 5.8 | 5.9 浏览器 AI | `use-08-browser-ai.png` | ✅ 已就位 | 浏览器 AI+ 会话页 | -| 图 7.1 | 七、常见问题 | `qrcode.png` | ✅ 已就位 | 客户企业微信服务群二维码(有效期至 2026-08-03) | +| 图 7.1 | 七、常见问题 | `qrcode.png` | 已撤下 | 客户企业微信群码已于 2026-08-03 失效,不再随文档分发;请用 GitHub Discussions 或 Discord | diff --git a/fnos/README.md b/fnos/README.md index db66a974..19f9bec1 100644 --- a/fnos/README.md +++ b/fnos/README.md @@ -39,13 +39,13 @@ octop backup --help # 备份/恢复 | 版本 | 包名 | 体积 | 运行方式 | 依赖 | |------|------|------|----------|------| -| **Docker 版** | `octop-.fpk` | ~8 KB | 飞牛自动从 GHCR 拉取 `ghcr.io/xyaistudio/freeos:latest` 镜像运行 | 宿主需有 Docker 运行时 | -| **本地版(非 Docker)** | `octop-native-.fpk` | ~560 MB | 自带 Python 3.12 运行时 + 前端 + 核心依赖,原生运行在飞牛主机 | 无需 Docker | +| **Docker 版** | `FreeOS-fnos-docker-.fpk` | ~8 KB | 飞牛自动从 GHCR 拉取 `ghcr.io/xyaistudio/freeos:latest` 镜像运行 | 宿主需有 Docker 运行时 | +| **本地版(非 Docker)** | `FreeOS-fnos-native-.fpk` | ~560 MB | 自带 Python 3.12 运行时 + 前端 + 核心依赖,原生运行在飞牛主机 | 无需 Docker | - **Docker 版**实现为 FnOS `docker-project`:包体只含 `docker-compose.yaml` 与向导配置,运行时由飞牛从 GHCR 拉取镜像。镜像已内置 `desktop` 桌面控制与前端;Playwright Chromium 不预装,可在控制台按需安装。 - **本地版**实现为 FnOS 原生 `app`:包内自带独立 Python 3.12 运行时、Octop 核心依赖、前端构建产物,以及 `data-share` 共享数据目录,直接以进程方式运行,不依赖 Docker。 -> 两款包随正式版一起挂在 **`v*` GitHub Release** 上(例如 [Releases](https://github.com/XYAIStudio/FreeOS/releases/latest)):`Octop-fnos-docker-.fpk` / `Octop-fnos-native-.fpk`。 +> 两款包随正式版一起挂在 **`v*` GitHub Release** 上(例如 [Releases](https://github.com/XYAIStudio/FreeOS/releases/latest)):`FreeOS-fnos-docker-.fpk` / `FreeOS-fnos-native-.fpk`。更早的 Release 资产可能仍使用 `Octop-fnos-*` 文件名。 ## 目录结构 @@ -89,14 +89,14 @@ fnos/ 1. **发版链路**:`v*` tag → `release.yml`(PyPI + GitHub Release)与 `docker-publish.yml`(GHCR / Hub)并行;Release 成功后自动 `workflow_dispatch` 本工作流。 2. **镜像复用**:不再重新 build 镜像;`ensure-image` 轮询等待 `ghcr.io/xyaistudio/freeos:{version}`(由 docker-publish 推送)。Docker 版 `.fpk` 仅打包 compose,运行时拉取该镜像。 3. **Wheel 复用**:Native 版优先从同版本 GitHub Release(`v*`)下载 `octop-*.whl`;若缺失再回退源码构建前端 + wheel。 -4. **安装包构建**:`fpk` / `native` job 用 `scripts/build-fpk.sh` 打包,产物 `Octop-fnos-docker-.fpk` / `Octop-fnos-native-.fpk` 挂到同一个 `v*` GitHub Release(与 wheel、桌面包并列)。 +4. **安装包构建**:`fpk` / `native` job 用 `scripts/build-fpk.sh` 打包,产物 `FreeOS-fnos-docker-.fpk` / `FreeOS-fnos-native-.fpk` 挂到同一个 `v*` GitHub Release(与 wheel、桌面包并列)。 ## 本地构建 .fpk(无需 Docker) ```bash -bash scripts/build-fpk.sh # 仅 Docker 版 → dist/Octop-fnos-docker-.fpk +bash scripts/build-fpk.sh # 仅 Docker 版 → dist/FreeOS-fnos-docker-.fpk bash scripts/build-fpk.sh docker # 仅 Docker 版 -bash scripts/build-fpk.sh native # 仅本地版 → dist/Octop-fnos-native-.fpk +bash scripts/build-fpk.sh native # 仅本地版 → dist/FreeOS-fnos-native-.fpk ``` `.fpk` 为「双层 gzip tar」:外层含 `app.tgz / cmd / config / wizard / ICON.PNG / ICON_256.PNG / LICENSE / manifest`,内层 `app.tgz` 含 `app/` 内容。 @@ -104,8 +104,8 @@ bash scripts/build-fpk.sh native # 仅本地版 → dist/Octop-fnos-nat ## 在飞牛上安装 1. 飞牛「应用中心 → 设置 → 手动安装应用」选择对应 `.fpk`: - - 想用 Docker 跑、主机已装 Docker → 选 `Octop-fnos-docker-.fpk` - - 不想依赖 Docker、希望自带运行时原生运行 → 选 `Octop-fnos-native-.fpk` + - 想用 Docker 跑、主机已装 Docker → 选 `FreeOS-fnos-docker-.fpk` + - 不想依赖 Docker、希望自带运行时原生运行 → 选 `FreeOS-fnos-native-.fpk` 2. 安装向导中设置管理员账号/密码、日志级别、LLM 密钥(可选)。 3. 安装完成后桌面出现「Octop AI 助手」图标,浏览器打开 `http://<设备IP>:8088`。 4. Docker 版镜像首次会从 GHCR `ghcr.io/xyaistudio/freeos:latest` 拉取;请确保该包为 Public(首次推送后可在 GitHub Packages 设置)。本地版无需联网拉镜像。Playwright Chromium 不预装,需要远程浏览器时在控制台按需安装。 diff --git a/modules/openxyos/package-lock.json b/modules/openxyos/package-lock.json index 19d726a1..f2561f3b 100644 --- a/modules/openxyos/package-lock.json +++ b/modules/openxyos/package-lock.json @@ -70,7 +70,7 @@ "tsx": "^4.19.0", "typescript": "^5.6.0", "typescript-eslint": "^8.64.0", - "vite": "^6.0.0", + "vite": "^6.4.5", "vite-plugin-pwa": "^1.3.0", "zustand": "^5.0.0" }, @@ -8280,9 +8280,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -13323,9 +13323,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 6f9d77ba..b515bd71 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -100,7 +100,7 @@ "tsx": "^4.19.0", "typescript": "^5.6.0", "typescript-eslint": "^8.64.0", - "vite": "^6.0.0", + "vite": "^6.4.5", "vite-plugin-pwa": "^1.3.0", "zustand": "^5.0.0" }, @@ -110,6 +110,8 @@ }, "overrides": { "qs": "6.16.0", + "follow-redirects": "1.16.1", + "vite": "^6.4.5", "katex": "^0.18.2", "exceljs": { "uuid": "^11.1.1" diff --git a/scripts/build-fpk.sh b/scripts/build-fpk.sh index c827e0b0..dc1846e5 100644 --- a/scripts/build-fpk.sh +++ b/scripts/build-fpk.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash # ============================================================================= -# 构建 Octop 飞牛 FnOS 安装包 (.fpk) +# 构建 FreeOS 飞牛 FnOS 安装包 (.fpk) # # 直接使用官方 fnpack CLI 打包(fnpack 在生成 .fpk 前会校验 manifest / cmd / # config / wizard / app 等结构,确保产物与飞牛 fnOS 安装校验完全一致)。 # # 用法(在仓库根目录执行): -# bash scripts/build-fpk.sh docker # 构建 Docker 版 -> dist/Octop-fnos-docker-.fpk -# bash scripts/build-fpk.sh native # 构建本地版(非Docker) -> dist/Octop-fnos-native-.fpk +# bash scripts/build-fpk.sh docker # 构建 Docker 版 -> dist/FreeOS-fnos-docker-.fpk +# bash scripts/build-fpk.sh native # 构建本地版(非Docker) -> dist/FreeOS-fnos-native-.fpk # bash scripts/build-fpk.sh # 两个都构建 # # 环境变量: -# FPK_NAME_PREFIX 输出文件名前缀,默认 "octop" -# 例如 FPK_NAME_PREFIX=Octop-fnos 会生成 Octop-fnos-docker-.fpk / Octop-fnos-native-.fpk +# FPK_NAME_PREFIX 输出文件名前缀,默认 "FreeOS-fnos" +# 生成 FreeOS-fnos-docker-.fpk / FreeOS-fnos-native-.fpk # FPK_ITER 迭代号,默认空 # 例如 FPK_ITER=01 会生成 ...--01.fpk(通常不需要,按版本号发布) # @@ -34,10 +34,10 @@ trap cleanup EXIT VER="$(grep -m1 '^version' "$ROOT/pyproject.toml" | sed -E 's/.*"([0-9][0-9.]*[0-9])".*/\1/')" [ -n "$VER" ] || { echo "无法从 pyproject.toml 解析版本"; exit 1; } -echo "[build-fpk] Octop 版本: $VER" +echo "[build-fpk] FreeOS 版本: $VER" -# 输出文件名前缀与迭代号(由 CI 传入,实现 Octop-fnos-docker-0.9.30.fpk 风格) -PREFIX="${FPK_NAME_PREFIX:-octop}" +# 输出文件名前缀与迭代号(默认 FreeOS-fnos-docker-.fpk) +PREFIX="${FPK_NAME_PREFIX:-FreeOS-fnos}" ITER_SUFFIX="" if [ -n "${FPK_ITER:-}" ]; then ITER_SUFFIX="-$FPK_ITER" diff --git a/scripts/release_download_links.py b/scripts/release_download_links.py index 00378222..98251e69 100644 --- a/scripts/release_download_links.py +++ b/scripts/release_download_links.py @@ -55,8 +55,8 @@ def render_download_section(raw_version: str) -> str: linux_arm = md_link("ARM64", tag, desktop_name("linux", "arm64", version, "tar.gz")) linux64_zip = md_link("64-bit", tag, portable_name("linux", "amd64", version)) linux_arm_zip = md_link("ARM64", tag, portable_name("linux", "arm64", version)) - fnos_docker = md_link("Docker", tag, f"Octop-fnos-docker-{version}.fpk") - fnos_native = md_link("Native", tag, f"Octop-fnos-native-{version}.fpk") + fnos_docker = md_link("Docker", tag, f"FreeOS-fnos-docker-{version}.fpk") + fnos_native = md_link("Native", tag, f"FreeOS-fnos-native-{version}.fpk") return "\n".join( [ "", diff --git a/tests/unit/test_release_download_links.py b/tests/unit/test_release_download_links.py index 53ccc266..8dca01d6 100644 --- a/tests/unit/test_release_download_links.py +++ b/tests/unit/test_release_download_links.py @@ -38,7 +38,7 @@ def test_render_download_section_matches_github_asset_names() -> None: assert f"{base}/FreeOS-desktop-linux-arm64-0.9.31.tar.gz" in body assert f"{base}/FreeOS-portable-linux-amd64-0.9.31.zip" in body assert f"{base}/FreeOS-portable-linux-arm64-0.9.31.zip" in body - assert f"{base}/Octop-fnos-docker-0.9.31.fpk" in body - assert f"{base}/Octop-fnos-native-0.9.31.fpk" in body + assert f"{base}/FreeOS-fnos-docker-0.9.31.fpk" in body + assert f"{base}/FreeOS-fnos-native-0.9.31.fpk" in body assert "pip install" not in body assert "## Downloads" in body From 65c5b5601c502096a503b65fcfddb59e9e05bd94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 22:06:15 +0000 Subject: [PATCH 08/39] fix(release): publish GitHub wheels as freeos-0.1.0 Set the distribution name to freeos so the wheel and sdist are freeos-0.1.0*. The import package and the octop CLI alias stay. FnOS, portable packaging, and release notes use the new filenames and say not to pip install octop. User-facing paths in the docs this change touches name ~/.octop as the data directory. FREEOS_HOME applies only when it is already set; this release does not migrate that directory to ~/.freeos. Co-authored-by: haoboy007-sudo --- .github/workflows/fnos-build-fpk.yml | 10 +++++----- .github/workflows/release.yml | 5 +++-- CHANGELOG.md | 6 +++--- README.md | 14 +++++-------- README_CN.md | 4 ++-- desktop/README.md | 24 +++++++++-------------- desktop/portable/package.sh | 10 +++++----- docs/architecture-integration.md | 11 +++++------ docs/user-guide.md | 2 +- fnos/README.md | 2 +- pyproject.toml | 4 ++-- scripts/release_download_links.py | 9 +++++++++ src/octop/cli/repl/render.py | 2 +- src/octop/infra/setup/self_update.py | 4 ++-- tests/unit/test_release_download_links.py | 5 ++++- uv.lock | 2 +- 16 files changed, 58 insertions(+), 56 deletions(-) diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 7b14e4e1..21829870 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -131,8 +131,8 @@ jobs: set -euo pipefail mkdir -p dist REL_TAG="v${VER}" - if gh release download "$REL_TAG" --repo "${{ github.repository }}" -p 'octop-*.whl' -D dist 2>/tmp/wheel-dl.err; then - ls -lh dist/octop-*.whl + if gh release download "$REL_TAG" --repo "${{ github.repository }}" -p 'freeos-*.whl' -D dist 2>/tmp/wheel-dl.err; then + ls -lh dist/freeos-*.whl echo "source=release" >> "$GITHUB_OUTPUT" echo "Reusing wheel from GitHub Release ${REL_TAG}" else @@ -169,7 +169,7 @@ jobs: python3 -m pip install --quiet build python3 -m build --wheel echo "=== built wheel ===" - ls -lh dist/octop-*.whl + ls -lh dist/freeos-*.whl - name: Install Octop core into app/site-packages (no Chromium, no preinstalled skills) id: install_core @@ -185,7 +185,7 @@ jobs: { python3 -m pip install --quiet uv python3 -m uv pip install --python python3.12 \ - --target "$SP" --no-cache "$(ls dist/octop-*.whl | head -1)" + --target "$SP" --no-cache "$(ls dist/freeos-*.whl | head -1)" rm -rf "$SP/mcp" "$SP"/mcp-*.dist-info \ "$SP/langchain_mcp_adapters" "$SP"/langchain_mcp_adapters-*.dist-info mkdir -p /tmp/octop-pinned @@ -195,7 +195,7 @@ jobs: echo "Extracting pinned wheel: $whl" python3.12 -m zipfile -e "$whl" "$SP" done - cp dist/octop-*.whl "$SP/octop.whl" + cp dist/freeos-*.whl "$SP/freeos.whl" PYTHONPATH="$SP" python3.12 \ -c "import octop, mcp, langchain_mcp_adapters; from octop.launch import run_foreground_blocking; print('octop', octop.__version__, '| mcp', mcp.__version__, '| lcma', langchain_mcp_adapters.__version__)" } 2>&1 | tee -a "$LOG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95ffa435..03c305ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,9 +115,10 @@ jobs: echo "" echo "## Install" echo "" - echo "Download the Python wheel from this GitHub Release and install it with:" + echo "**Do not** \`pip install octop\`. PyPI \`octop\` is upstream Octop, not this FreeOS release." + echo "Install the wheel attached to this GitHub Release:" echo '```bash' - echo "uv pip install ./octop-$version-py3-none-any.whl" + echo "uv pip install ./freeos-$version-py3-none-any.whl" echo '```' } > .release-notes.md fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 09aa70ac..83341437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,12 @@ ## [0.1.0] - TBD -FreeOS **0.1.0** is an independent semver baseline. It does not track or align with Octop 1.0.x. Python wheels stay on GitHub Releases; this release does not publish the colliding `octop` name to PyPI. +FreeOS **0.1.0** is an independent semver baseline. It does not track or align with Octop 1.0.x. GitHub Release Python artifacts are `freeos-0.1.0*` (sdist and wheel). The import package stays `octop`. This release does not publish to PyPI, and `pip install octop` installs upstream Octop, not FreeOS. Older Release files may still be named `octop-0.0.10*`. ### Upgrade from 0.0.10 -- In-app update and a manual reinstall both keep `~/.freeos` (legacy `~/.octop` is still recognized): sessions, local models, knowledge bases, organization data, and `config.json`. -- Desktop portable upgrades copy a SQLite backup under `$FREEOS_HOME/backups` before replacing the runtime, and checkpoint the WAL before the process restarts. +- In-app update and a manual reinstall keep the data directory the process already uses. Today that directory is `~/.octop` (`OCTOP_HOME`): sessions, local models, knowledge bases, organization data, and `config.json`. `FREEOS_HOME` is honored only when it is already set. This release does not migrate `~/.octop` to `~/.freeos`. +- Desktop portable upgrades copy a SQLite backup under the data directory's `backups/` before replacing the runtime, and checkpoint the WAL before the process restarts. - A `config.json` that cannot be parsed is left in place and reported. It is not rewritten as an empty file. - Roll back by installing the **v0.0.10** desktop package again, or restore a backup with `octop backup restore`. Do not point install scripts at PyPI `octop`; that package is upstream Octop, not this release. Use [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) or `scripts/install.sh` (add `--from-pypi` only when you explicitly want upstream Octop). diff --git a/README.md b/README.md index 237b8afa..369537f8 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,9 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md). 3. 打开 **FreeOS**。第一次启动会解压内置运行环境(可能要一两分钟),然后直接进入可用会话,无需先登录。 4. 保存、导出或发布到账号时再在本机完成注册或登录(更广的服务或授权不挡起步)。侧栏 **Organization** 当前默认走宿主内组织能力;桌面安装包附带的托管 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)。 +**不要**执行 `pip install octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS。本仓库的 Python 制品在 GitHub Releases 上,文件名是 `freeos--py3-none-any.whl` 和 `freeos-.tar.gz`(导入包仍是 `octop`)。桌面安装包仍是 `FreeOS-desktop-*`。 + +数据目录是 `%USERPROFILE%\.octop`(也可用 `OCTOP_HOME` 指定)。已经设置的 `FREEOS_HOME` 会被沿用;这一版不会把 `~/.octop` 迁到 `~/.freeos`。卸载安装包会清空安装目录(默认为 `Program Files\FreeOS`)并删除快捷方式,但**不会**删除该用户数据目录;详见 [desktop/README.md](desktop/README.md#windows-uninstall)。 安装包由 CI 工作流 **FreeOS Desktop Package**(文件名仍是 `.github/workflows/octop-desktop.yml`,给现有发版脚本用)在 Windows runner 上打出来。本仓库的云环境打不出 `.exe`;合并后由该 job 产出。 @@ -168,13 +170,7 @@ FreeOS prefers runtimes and files on this computer. Cloud vendors are optional. | Knowledge | Sidebar **Knowledge Bases**: mount a local folder first. Local ONNX embeddings are the default vector path. ima / WeKnora remain optional connectors. | | Detail | [User guide](docs/user-guide.md#四配置模型llm-供应商) · [Configuration](docs/configuration.md#local-models-and-knowledge-bases) · [Product contract](docs/product-contract.md#local-models-and-knowledge-bases) | -Data directory precedence: - -1. `FREEOS_HOME` -2. `OCTOP_HOME` (legacy) -3. existing `~/.freeos` -4. existing `~/.octop` -5. new installs → `~/.freeos` +Data directory: `~/.octop` (`OCTOP_HOME`). If `FREEOS_HOME` is already set, that path is used. This release does not migrate `~/.octop` to `~/.freeos`. ### Rebuild the dashboard (optional) @@ -239,7 +235,7 @@ Inbound import compiles the blueprint **and** registers a FreeOS agent (`org-pol ## Host platform features (from Octop) -Unchanged in this fork: multi-user JWT, multi-agent chat, expert library, connectors, ACP, knowledge bases, bundled plugins, cron, terminal/browser/desktop surfaces. The **Windows desktop installer** is branded **FreeOS** and defaults to `~/.freeos` / `FREEOS_HOME`. The Python package, `octop` CLI alias, `OCTOP_*` env vars, and `octop.db` stay for compatibility. +Unchanged in this fork: multi-user JWT, multi-agent chat, expert library, connectors, ACP, knowledge bases, bundled plugins, cron, terminal/browser/desktop surfaces. The **Windows desktop installer** is branded **FreeOS**. User data stays in `~/.octop` (`OCTOP_HOME`); `FREEOS_HOME` is honored only when it is already set. The import package, `octop` CLI alias, `OCTOP_*` env vars, and `octop.db` stay for compatibility. GitHub Release wheels are named `freeos-*`. Longer host docs: [docs/user-guide.md](docs/user-guide.md), [docs/configuration.md](docs/configuration.md), [docs/architecture.md](docs/architecture.md), [README_CN.md](README_CN.md). diff --git a/README_CN.md b/README_CN.md index a708ad06..c02c6de7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -247,7 +247,7 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install 桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。独立商业组织站见 [docs/org-export.md](docs/org-export.md)(`freeos org export-standalone`,默认导出完整 openXYOS 源码树),不是默认安装器里的第二套 Node。 -**不要**从 PyPI 安装 `octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS 0.1.0。桌面安装包从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载;终端安装用上面的安装脚本(默认拉取 FreeOS GitHub 源码)。只有在你明确要上游 Octop 时才加 `--from-pypi`。可选组件仍通过安装脚本传递,例如 `--extras browser`。 +**不要** `pip install octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS 0.1.0。本仓库发到 GitHub Releases 的 Python 制品是 `freeos--py3-none-any.whl` 和 `freeos-.tar.gz`(导入包仍叫 `octop`)。更早的 Release 里可能还有 `octop-0.0.10*` 文件名,那是旧制品名,不是 PyPI 安装命令。桌面安装包从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载;终端安装用上面的安装脚本(默认拉取 FreeOS GitHub 源码)。只有在你明确要上游 Octop 时才加 `--from-pypi`。可选组件仍通过安装脚本传递,例如 `--extras browser`。 从源码用 uv 开发时: @@ -335,7 +335,7 @@ docker run -d \ | 远程一键安装 | Windows | `irm …/octop/install.ps1 \| iex` 或 `install.bat` | | 本地脚本 | macOS / Linux | `bash scripts/install.sh` | | 本地脚本 | Windows | `scripts\install.bat` 或 `install.ps1` | -| GitHub Releases | 全平台 | 桌面安装包与 wheel。不要安装 PyPI 上的上游 `octop` | +| GitHub Releases | 全平台 | 桌面安装包,以及 `freeos--py3-none-any.whl` / `freeos-.tar.gz`。**不要** `pip install octop`(那是上游 PyPI 包) | | Docker | 全平台 | `docker/docker-compose.yml` | 所有安装脚本均在 `~/.octop/venv` 创建隔离环境,并通过 `~/.octop/bin/octop` 包装 CLI,不会影响系统 Python。 diff --git a/desktop/README.md b/desktop/README.md index 9513b7c1..03f26972 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -14,13 +14,7 @@ Windows end-user product: **download the NSIS `.exe` → install → open FreeOS ## Data directory -Same precedence as the FreeOS CLI/server: - -1. `FREEOS_HOME` -2. `OCTOP_HOME` (legacy) -3. existing `~/.freeos` -4. existing `~/.octop` -5. new installs → `~/.freeos` +Today the data directory is `~/.octop` (`OCTOP_HOME`). If `FREEOS_HOME` is already set, that path is used. This release does not migrate `~/.octop` to `~/.freeos`. - Green runtime extract → `{home}/portable/` - Organization data (in-host) → `{home}/tenants/`, `{home}/openxyos-mirror/`, `{home}/org-skills/` @@ -48,7 +42,7 @@ The finish page offers **运行 FreeOS** / **Run FreeOS**, checked by default: leave it checked and click Finish to start FreeOS from `$INSTDIR`, or uncheck it and click Finish to close Setup without launching. Setup does **not** auto-launch or auto-close. The launch uses the unelevated -explorer token so the first run does not stamp `%USERPROFILE%\.freeos` +explorer token so the first run does not stamp `%USERPROFILE%\.octop` as High integrity. Silent (`/S`) installs stay headless and do not launch. Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. @@ -60,12 +54,12 @@ Upgrade / reinstall (same version included) refreshes the extracted runtime: handed off to the old process. 2. Setup writes `$INSTDIR\FREEOS_INSTALL_STAMP` (product version + tick count, unique per Setup run) and deletes - `%USERPROFILE%\.freeos\portable\FREEOS_STAMP` (also `FREEOS_HOME` / - `OCTOP_HOME` / leftover `~/.octop/portable` when those env vars are - set). User data (`octop.db`, settings, chats) is not deleted. + `%USERPROFILE%\.octop\portable\FREEOS_STAMP` (also `OCTOP_HOME`, or + `FREEOS_HOME` when that variable is already set). User data + (`octop.db`, settings, chats) is not deleted. 3. The next launch compares the install stamp and the zip `FREEOS_STAMP`. Either mismatch re-extracts `packages/` and the embedded dashboard from - the new installer into `~/.freeos/portable`, then copies the install + the new installer into `~/.octop/portable`, then copies the install stamp into that tree. Later opens skip the extract until the next Setup run. If renaming the live `portable` directory to `portable.previous` fails because Windows still has the folder open @@ -114,7 +108,7 @@ the sidecar-era `v0.0.1` Windows builds. **Keeps** -- `%USERPROFILE%\.freeos` (or `FREEOS_HOME` / `OCTOP_HOME` / legacy `~/.octop`): +- `%USERPROFILE%\.octop` (or `OCTOP_HOME`; `FREEOS_HOME` only when already set): workspaces, `octop.db`, memories, settings, logs, and the extracted portable runtime under `{home}/portable/` - Documented exceptions **inside** the install directory, if you created them: @@ -202,7 +196,7 @@ cd desktop/src OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev ``` -Without `OCTOP_DESKTOP_URL`, first launch uses `~/.freeos/portable/` if valid +Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if valid **and** it already matches this install (same `FREEOS_STAMP` / install stamp); otherwise it extracts the matching zip shipped with the desktop package (embedded in the Windows and Linux binaries, under `Contents/Resources` on @@ -226,7 +220,7 @@ GitHub Release names follow `FreeOS----.`: into `Program Files\FreeOS` and creates Start Menu + desktop shortcuts), `.tar.gz` (Linux) - Green runtime zip: `FreeOS-portable--.zip` -- PyPI wheels stay `octop--py3-none-any.whl` (PEP 427) +- Python wheels on GitHub Releases are `freeos--py3-none-any.whl` (import package remains `octop`). Do not `pip install octop`; PyPI `octop` is upstream Octop. This repo does not publish that name. Older `Octop-portable-*` / `Octop-.zip` files beside the binary are still accepted when extracting a local/dev build. They are **not** published anymore. diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh index e8eeb411..067b4011 100755 --- a/desktop/portable/package.sh +++ b/desktop/portable/package.sh @@ -31,8 +31,8 @@ require_uv build_octop_wheel() { local wheel_dir="$1" mkdir -p "$wheel_dir" - # Drop previous local octop wheels so we pick the fresh build. - find "$wheel_dir" -maxdepth 1 -type f -name 'octop-*.whl' -delete 2>/dev/null || true + # Drop previous local wheels so we pick the fresh build. + find "$wheel_dir" -maxdepth 1 -type f \( -name 'octop-*.whl' -o -name 'freeos-*.whl' \) -delete 2>/dev/null || true if [[ ! -f "${REPO_ROOT}/src/octop/dashboard/index.html" ]]; then echo "[package] dashboard not built (src/octop/dashboard/index.html missing)." >&2 echo " Run: make build-frontend" >&2 @@ -42,12 +42,12 @@ build_octop_wheel() { echo "[package] dashboard boot splash is missing xyai-mark.png — refuse to ship Octop-branded UI." >&2 exit 1 fi - echo "[package] building octop wheel → ${wheel_dir}" >&2 + echo "[package] building freeos wheel → ${wheel_dir}" >&2 uv build --wheel --out-dir "$wheel_dir" "$REPO_ROOT" >&2 local whl - whl="$(ls -1 "${wheel_dir}"/octop-*.whl 2>/dev/null | sort | tail -1 || true)" + whl="$(ls -1 "${wheel_dir}"/freeos-*.whl 2>/dev/null | sort | tail -1 || true)" if [[ -z "$whl" ]]; then - echo "[package] failed to build octop-*.whl" >&2 + echo "[package] failed to build freeos-*.whl" >&2 exit 1 fi # stdout: wheel path only (captured by caller) diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index cf2dc5ba..9e8b098a 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -100,7 +100,7 @@ and applies asset packs onto openXYOS-shaped **in-host** surfaces │ in-host org storage + xyos-governance-mcp │ └───────┬───────────────────┬─────────────────┘ │ optional HTTP │ durable pause - │ if opted in │ ~/.freeos/governance/ + │ if opted in │ ~/.octop/governance/ ┌───────▼─────────┐ ┌─────▼─────────────────┐ Optional │ openXYOS :3780 │ │ Pause + audit JSONL │ Node sidecar │ export / sync │ │ Human must approve │ @@ -128,7 +128,7 @@ Agent → tool (outbound / delete / pay / prod) → optional POST {sidecar}/api/governance/validate → no matching allow rule → DEFAULT DENY → write audit.jsonl (and sidecar log when reachable) - → create durable pause ~/.freeos/governance/pauses/.json + → create durable pause ~/.octop/governance/pauses/.json → return execute=false (caller MUST stop) Human → FreeOS/Octop IM /approve or `freeos org governance approve ` → pause status=approved @@ -287,9 +287,8 @@ The Phase A work sits on the existing FreeOS bootstrap: - Octop history as the host (`src/octop/`, `dashboard/`) - openXYOS vendored at `modules/openxyos/` (subtree `@ 1bdba35f`) - Bundled plugin `org-os`, BFF `/api/org-module`, CLI `freeos org` -- PathLayout: `FREEOS_HOME` > `OCTOP_HOME` > existing `~/.freeos` > - existing `~/.octop` > new `~/.freeos` -- Package name stays `octop`; CLI alias `freeos` +- Data directory today: `~/.octop` (`OCTOP_HOME`). `FREEOS_HOME` is honored when already set. This release does not migrate `~/.octop` to `~/.freeos`. +- Distribution name is `freeos` (GitHub Release wheels). Imports stay `octop.*`. CLI aliases: `freeos` and `octop`. ### Capability map (host + sidecar) @@ -348,7 +347,7 @@ without `includes_org` leave those live directories untouched on restore. ## Remaining `octop` identifiers (intentional) -- Python package / imports: `octop.*` (`pyproject.toml` `name = "octop"`) +- Python imports stay `octop.*`. The distribution name in `pyproject.toml` is `freeos` (GitHub Release wheels `freeos-*`, not PyPI) - CLI: `octop` still works; `freeos` is the same entry point - Env: `OCTOP_*` still honored; prefer `FREEOS_*` - SQLite filename: `octop.db` diff --git a/docs/user-guide.md b/docs/user-guide.md index 0729a0cc..96b6cf98 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,6 +1,6 @@ # Octop 用户帮助文档 -> **Historical vs Current.** 本文沿自 Octop 宿主操作说明(安装、向导、模型、对话)。**Current:** 产品是 [FreeOS](../README.zh-CN.md);权威意图见 [产品契约](product-contract.zh-CN.md)。数据目录优先 `FREEOS_HOME` / `~/.freeos`(仍识别 `~/.octop`)。先在本机安顿好;组织能力像工作室里另一间可独立布置的房间。 +> **Historical vs Current.** 本文沿自 Octop 宿主操作说明(安装、向导、模型、对话)。**Current:** 产品是 [FreeOS](../README.zh-CN.md);权威意图见 [产品契约](product-contract.zh-CN.md)。数据目录今天是 `~/.octop`(`OCTOP_HOME`)。已经设置的 `FREEOS_HOME` 会沿用;这一版不会把 `~/.octop` 迁到 `~/.freeos`。先在本机安顿好;组织能力像工作室里另一间可独立布置的房间。 > 本帮助文档面向最终用户,介绍 **安装 → 设置向导 → 配置模型 → 基本使用** 的完整流程。 > 所有运行时数据默认存放在 `~/.octop/`(可通过 `OCTOP_HOME` 覆盖)。 diff --git a/fnos/README.md b/fnos/README.md index 19f9bec1..b8483e5a 100644 --- a/fnos/README.md +++ b/fnos/README.md @@ -88,7 +88,7 @@ fnos/ 1. **发版链路**:`v*` tag → `release.yml`(PyPI + GitHub Release)与 `docker-publish.yml`(GHCR / Hub)并行;Release 成功后自动 `workflow_dispatch` 本工作流。 2. **镜像复用**:不再重新 build 镜像;`ensure-image` 轮询等待 `ghcr.io/xyaistudio/freeos:{version}`(由 docker-publish 推送)。Docker 版 `.fpk` 仅打包 compose,运行时拉取该镜像。 -3. **Wheel 复用**:Native 版优先从同版本 GitHub Release(`v*`)下载 `octop-*.whl`;若缺失再回退源码构建前端 + wheel。 +3. **Wheel 复用**:Native 版优先从同版本 GitHub Release(`v*`)下载 `freeos-*.whl`;若缺失再回退源码构建前端 + wheel。更早的 Release 上可能仍是 `octop-*.whl`,那不是 PyPI 上的上游包。 4. **安装包构建**:`fpk` / `native` job 用 `scripts/build-fpk.sh` 打包,产物 `FreeOS-fnos-docker-.fpk` / `FreeOS-fnos-native-.fpk` 挂到同一个 `v*` GitHub Release(与 wheel、桌面包并列)。 ## 本地构建 .fpk(无需 Docker) diff --git a/pyproject.toml b/pyproject.toml index 0b9c75a9..37553b83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling>=1.18"] build-backend = "hatchling.build" [project] -name = "octop" +name = "freeos" version = "0.1.0" -description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" +description = "FreeOS — self-hosted multi-user multi-agent assistant. GitHub Release artifacts are named freeos-*; the import package stays octop. Not published to PyPI." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12" diff --git a/scripts/release_download_links.py b/scripts/release_download_links.py index 98251e69..38c7954a 100644 --- a/scripts/release_download_links.py +++ b/scripts/release_download_links.py @@ -89,6 +89,15 @@ def render_download_section(raw_version: str) -> str: "### fnOS NAS", f"- {fnos_docker} | {fnos_native}", "", + "### Python wheel and sdist", + "", + "**Do not** `pip install octop`. PyPI `octop` is upstream Octop, not FreeOS.", + "These GitHub Release files are the FreeOS distribution" + " (import package remains `octop`):", + "", + f"- {md_link('Wheel', tag, f'freeos-{version}-py3-none-any.whl')}", + f"- {md_link('sdist', tag, f'freeos-{version}.tar.gz')}", + "", ] ) diff --git a/src/octop/cli/repl/render.py b/src/octop/cli/repl/render.py index 34d7d246..55155f1f 100644 --- a/src/octop/cli/repl/render.py +++ b/src/octop/cli/repl/render.py @@ -288,7 +288,7 @@ def print_welcome(*, agent_id: str, model: str, session_key: str, thread_id: str console = Console() try: - ver = pkg_version("octop") + ver = pkg_version("freeos") except Exception: ver = "unknown" console.print(f" [bold green]Octop[/] [dim]v{ver}[/] — interactive chat") diff --git a/src/octop/infra/setup/self_update.py b/src/octop/infra/setup/self_update.py index 695b82ba..5aed9f1e 100644 --- a/src/octop/infra/setup/self_update.py +++ b/src/octop/infra/setup/self_update.py @@ -29,7 +29,7 @@ logger = logging.getLogger(__name__) -_PACKAGE_NAME = "octop" +_PACKAGE_NAME = "freeos" _GREEN_PACKAGES_ENV = "OCTOP_GREEN_PACKAGES" PENDING_PORTABLE_NAME = "pending-portable.zip" PENDING_META_NAME = "pending.json" @@ -429,7 +429,7 @@ def get_version_in_dir(python_exe: str, target: str) -> str | None: try: code = ( "import sys; sys.path.insert(0, sys.argv[1]); " - "from importlib.metadata import version; print(version('octop'))" + "from importlib.metadata import version; print(version('freeos'))" ) result = subprocess.run( [python_exe, "-c", code, target], diff --git a/tests/unit/test_release_download_links.py b/tests/unit/test_release_download_links.py index 8dca01d6..b52a9aa4 100644 --- a/tests/unit/test_release_download_links.py +++ b/tests/unit/test_release_download_links.py @@ -40,5 +40,8 @@ def test_render_download_section_matches_github_asset_names() -> None: assert f"{base}/FreeOS-portable-linux-arm64-0.9.31.zip" in body assert f"{base}/FreeOS-fnos-docker-0.9.31.fpk" in body assert f"{base}/FreeOS-fnos-native-0.9.31.fpk" in body - assert "pip install" not in body + assert f"{base}/freeos-0.9.31-py3-none-any.whl" in body + assert f"{base}/freeos-0.9.31.tar.gz" in body + assert "octop-0.9.31" not in body + assert "Do not** `pip install octop`" in body assert "## Downloads" in body diff --git a/uv.lock b/uv.lock index 6b273614..0662c6ff 100644 --- a/uv.lock +++ b/uv.lock @@ -2502,7 +2502,7 @@ wheels = [ ] [[package]] -name = "octop" +name = "freeos" version = "0.1.0" source = { editable = "." } dependencies = [ From b2f696451a6998d30a6718db5947f6b208278eff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 22:13:44 +0000 Subject: [PATCH 09/39] fix(install): stop pointing users at the upstream Octop script README and the user guide no longer recommend the Tencent COS octop/install URLs. Those scripts install PyPI octop. Desktop packages on GitHub Releases are the primary install path; CLI installs use this repo's develop scripts. New installs use ~/.freeos. install.sh, install.ps1, and install.bat follow PathLayout: FREEOS_HOME, OCTOP_HOME, an existing ~/.freeos, an existing ~/.octop when ~/.freeos is absent, otherwise ~/.freeos. The desktop app still does not adopt a leftover ~/.octop. Co-authored-by: haoboy007-sudo --- CHANGELOG.md | 2 +- README.md | 6 +-- README_CN.md | 57 +++++++++++++------------- desktop/README.md | 17 ++++---- docs/architecture-integration.md | 6 +-- docs/user-guide.html | 26 ++++++------ docs/user-guide.md | 36 +++++++++-------- scripts/README.md | 17 +++++--- scripts/install.bat | 18 +++++++-- scripts/install.ps1 | 50 ++++++++++++++++++++--- scripts/install.sh | 69 +++++++++++++++++++++++++++++--- 11 files changed, 208 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83341437..07eaa8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ FreeOS **0.1.0** is an independent semver baseline. It does not track or align w ### Upgrade from 0.0.10 -- In-app update and a manual reinstall keep the data directory the process already uses. Today that directory is `~/.octop` (`OCTOP_HOME`): sessions, local models, knowledge bases, organization data, and `config.json`. `FREEOS_HOME` is honored only when it is already set. This release does not migrate `~/.octop` to `~/.freeos`. +- In-app update and a manual reinstall keep the data directory the process already uses. New installs use `~/.freeos` (`FREEOS_HOME`, then `OCTOP_HOME`, then an existing `~/.freeos`; the desktop app does not adopt a leftover `~/.octop`). CLI installs keep an existing `~/.octop` when `~/.freeos` is not there yet. Sessions, local models, knowledge bases, organization data, and `config.json` stay in that directory. This release does not copy one tree into the other. - Desktop portable upgrades copy a SQLite backup under the data directory's `backups/` before replacing the runtime, and checkpoint the WAL before the process restarts. - A `config.json` that cannot be parsed is left in place and reported. It is not rewritten as an empty file. - Roll back by installing the **v0.0.10** desktop package again, or restore a backup with `octop backup restore`. Do not point install scripts at PyPI `octop`; that package is upstream Octop, not this release. Use [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) or `scripts/install.sh` (add `--from-pypi` only when you explicitly want upstream Octop). diff --git a/README.md b/README.md index 369537f8..09bbf633 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md). **不要**执行 `pip install octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS。本仓库的 Python 制品在 GitHub Releases 上,文件名是 `freeos--py3-none-any.whl` 和 `freeos-.tar.gz`(导入包仍是 `octop`)。桌面安装包仍是 `FreeOS-desktop-*`。 -数据目录是 `%USERPROFILE%\.octop`(也可用 `OCTOP_HOME` 指定)。已经设置的 `FREEOS_HOME` 会被沿用;这一版不会把 `~/.octop` 迁到 `~/.freeos`。卸载安装包会清空安装目录(默认为 `Program Files\FreeOS`)并删除快捷方式,但**不会**删除该用户数据目录;详见 [desktop/README.md](desktop/README.md#windows-uninstall)。 +新安装的数据目录是 `%USERPROFILE%\.freeos`(`FREEOS_HOME` 优先于 `OCTOP_HOME`)。桌面端不会把残留的 `~/.octop` 当成家目录。命令行安装在还没有 `~/.freeos`、但已经有 `~/.octop` 时继续用旧目录。卸载安装包会清空安装目录(默认为 `Program Files\FreeOS`)并删除快捷方式,但**不会**删除该用户数据目录;详见 [desktop/README.md](desktop/README.md#windows-uninstall)。 安装包由 CI 工作流 **FreeOS Desktop Package**(文件名仍是 `.github/workflows/octop-desktop.yml`,给现有发版脚本用)在 Windows runner 上打出来。本仓库的云环境打不出 `.exe`;合并后由该 job 产出。 @@ -170,7 +170,7 @@ FreeOS prefers runtimes and files on this computer. Cloud vendors are optional. | Knowledge | Sidebar **Knowledge Bases**: mount a local folder first. Local ONNX embeddings are the default vector path. ima / WeKnora remain optional connectors. | | Detail | [User guide](docs/user-guide.md#四配置模型llm-供应商) · [Configuration](docs/configuration.md#local-models-and-knowledge-bases) · [Product contract](docs/product-contract.md#local-models-and-knowledge-bases) | -Data directory: `~/.octop` (`OCTOP_HOME`). If `FREEOS_HOME` is already set, that path is used. This release does not migrate `~/.octop` to `~/.freeos`. +Data directory for a new install: `~/.freeos`. Order: `FREEOS_HOME`, `OCTOP_HOME`, an existing `~/.freeos`, an existing `~/.octop` (CLI only; the desktop app does not adopt a leftover `~/.octop`), otherwise `~/.freeos`. This release does not copy an old tree into the new one. ### Rebuild the dashboard (optional) @@ -235,7 +235,7 @@ Inbound import compiles the blueprint **and** registers a FreeOS agent (`org-pol ## Host platform features (from Octop) -Unchanged in this fork: multi-user JWT, multi-agent chat, expert library, connectors, ACP, knowledge bases, bundled plugins, cron, terminal/browser/desktop surfaces. The **Windows desktop installer** is branded **FreeOS**. User data stays in `~/.octop` (`OCTOP_HOME`); `FREEOS_HOME` is honored only when it is already set. The import package, `octop` CLI alias, `OCTOP_*` env vars, and `octop.db` stay for compatibility. GitHub Release wheels are named `freeos-*`. +Unchanged in this fork: multi-user JWT, multi-agent chat, expert library, connectors, ACP, knowledge bases, bundled plugins, cron, terminal/browser/desktop surfaces. The **Windows desktop installer** is branded **FreeOS** and stores new data in `~/.freeos`. The import package, `octop` CLI alias, `OCTOP_*` env vars, and `octop.db` stay for compatibility. GitHub Release wheels are named `freeos-*`. Do not install from the Tencent COS `octop/install.*` URLs; those scripts install upstream PyPI `octop`. Longer host docs: [docs/user-guide.md](docs/user-guide.md), [docs/configuration.md](docs/configuration.md), [docs/architecture.md](docs/architecture.md), [README_CN.md](README_CN.md). diff --git a/README_CN.md b/README_CN.md index c02c6de7..d0706fe3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -91,7 +91,7 @@ FreeOS 诞生于一个简单而清晰的目标:在 Octop 的自托管多智能 | 💻 | **终端 AI+** | 浏览器内交互式 Shell,AI 辅助命令执行与排障 | | 🌐 | **浏览器 AI+** | 基于 Chromium 的无头浏览器会话,支持网页自动化、截图与远程操控 | | 🖥️ | **远程桌面** | 控制台内实时看屏与键鼠操控,跨 Linux / Windows / macOS;适合远程办公、GUI 软件操作,无图形 Linux 可一键搭建隔离桌面 | -| 🏠 | **可自托管** | 一条 `octop run` 即可运行控制台、CLI、IM 通道与定时任务,数据存于 `~/.octop/` | +| 🏠 | **可自托管** | 一条 `octop run` 即可运行控制台、CLI、IM 通道与定时任务,新安装的数据在 `~/.freeos/` |
🐾 你能用 Octop 做什么 @@ -128,7 +128,7 @@ Octop 基于一系列 Harness 工程实践构建——它将这些专注的运 - **harness-memory** — 分层记忆与全文检索,让 Agent 的记忆随工作区一同迁移。 - **harness-browser** — 基于 CDP 的浏览器自动化,支持持久化配置,用于网页类任务。 -Octop 不依赖外部消息队列或中间件,而是通过进程内的 `HarnessProcessor` 统一路由所有入口——Web UI、IM 与定时任务。最终呈现为一个可重启恢复的单进程:启动时整个状态都从控制面数据库重建(默认 `~/.octop/octop.db`,亦可配置 PostgreSQL)。 +Octop 不依赖外部消息队列或中间件,而是通过进程内的 `HarnessProcessor` 统一路由所有入口——Web UI、IM 与定时任务。最终呈现为一个可重启恢复的单进程:启动时整个状态都从控制面数据库重建(新安装默认 `~/.freeos/octop.db`;已有 `~/.octop` 且没有 `~/.freeos` 时仍用旧目录,亦可配置 PostgreSQL)。 ## 🤔 功能特性 @@ -192,30 +192,32 @@ Octop 支持两个方向的 ACP 集成: ### 环境要求 - **macOS / Linux / Windows** -- 无需预先安装 Python — 安装脚本通过 [uv](https://docs.astral.sh/uv/) 在 `~/.octop/` 下创建隔离的 Python 3.12 虚拟环境 +- 无需预先安装 Python — 安装脚本通过 [uv](https://docs.astral.sh/uv/) 在 `~/.freeos/` 下创建隔离的 Python 3.12 虚拟环境(若还没有 `~/.freeos`、但已有 `~/.octop`,则继续用旧目录) - 现代多核 CPU,并预留数 GB 内存供进程与模型/Embedding 缓存使用;磁盘需容纳数据库、Agent 工作区与文档语料 ### 1. 安装 -**macOS / Linux** — 一键安装(推荐): +**推荐:桌面安装包。** 从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases/latest) 下载 `FreeOS-desktop-*`,安装后打开 FreeOS。 + +**命令行 / 源码**(安装本仓库脚本,不是上游 Octop)。打出版本标签之后,Release 说明会改成固定标签;当前用 `develop`: ```bash -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh | bash ``` -**Windows(PowerShell)**: - ```powershell -irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 | iex +irm https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.ps1 | iex ``` -**Windows(cmd)** — 下载后运行,或从已克隆的仓库执行: - ```bat -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.bat -o install.bat +curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.bat -o install.bat install.bat ``` +已克隆本仓库时:`bash scripts/install.sh`,或 Windows 的 `scripts\install.ps1` / `scripts\install.bat`。 + +**不要**使用 `finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh`(以及同目录的 `.ps1` / `.bat`)。那是上游 Octop 脚本,默认安装 PyPI 上的 `octop`,不是 FreeOS。 + 安装完成后,请打开**新终端**,或重新加载 shell 配置: ```bash @@ -224,14 +226,11 @@ source ~/.zshrc # Zsh source ~/.bashrc # Bash ``` -安装脚本会将 `octop` 加入 PATH(`~/.octop/bin`)。可选附加组件: +新安装会把 `octop` 加入 PATH(`~/.freeos/bin`)。可选附加组件: ```bash -# 浏览器自动化(Playwright Chromium) -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash -s -- --extras browser - -# 飞书通道支持 -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash -s -- --extras channels-feishu +curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh | bash -s -- --extras browser +curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh | bash -s -- --extras channels-feishu ``` 完整安装选项见 [scripts/README.md](scripts/README.md)(`--version`、`--from-source`、`--mirror` 及 Windows 参数)。 @@ -331,18 +330,16 @@ docker run -d \ | 方式 | 平台 | 说明 | |------|------|------| -| 远程一键安装 | macOS / Linux | `curl …/octop/install.sh \| bash` | -| 远程一键安装 | Windows | `irm …/octop/install.ps1 \| iex` 或 `install.bat` | -| 本地脚本 | macOS / Linux | `bash scripts/install.sh` | -| 本地脚本 | Windows | `scripts\install.bat` 或 `install.ps1` | -| GitHub Releases | 全平台 | 桌面安装包,以及 `freeos--py3-none-any.whl` / `freeos-.tar.gz`。**不要** `pip install octop`(那是上游 PyPI 包) | +| GitHub Releases(推荐) | 全平台 | `FreeOS-desktop-*` 桌面安装包,以及 `freeos--py3-none-any.whl` / `freeos-.tar.gz`。**不要** `pip install octop`(那是上游 PyPI 包) | +| 本仓库脚本 | macOS / Linux | `bash scripts/install.sh`,或 `raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh` | +| 本仓库脚本 | Windows | `scripts\install.ps1` / `install.bat`,或同路径的 develop raw 脚本 | | Docker | 全平台 | `docker/docker-compose.yml` | -所有安装脚本均在 `~/.octop/venv` 创建隔离环境,并通过 `~/.octop/bin/octop` 包装 CLI,不会影响系统 Python。 +新安装的数据目录是 `~/.freeos`:脚本在 `~/.freeos/venv` 创建隔离环境,并用 `~/.freeos/bin/octop` 包装 CLI,不会影响系统 Python。若还没有 `~/.freeos`、但已经有 `~/.octop`,脚本和 CLI 继续用旧目录。`FREEOS_HOME` 优先于 `OCTOP_HOME`。桌面端不会把残留的 `~/.octop` 当成家目录。 ### 升级 -`octop update` 只替换 wheel / 二进制,你的 `~/.octop/` 数据库、工作区、密钥与 `config.json` 均会保留: +`octop update` 只替换 wheel / 二进制。数据留在当前家目录(新安装是 `~/.freeos/`;已在使用的 `~/.octop/` 会保留)里的数据库、工作区、密钥与 `config.json`: ```bash octop update # 获取并安装最新版 Octop,若已注册系统服务则自动重启 @@ -352,7 +349,7 @@ octop update # 获取并安装最新版 Octop,若已注册系统服 ### ⚙️ 配置 -所有运行时数据存放在 `~/.octop/`。可通过 CLI 管理,也可直接编辑文件。 +新安装的运行时数据在 `~/.freeos/`。已有 `~/.octop/` 且还没有 `~/.freeos/` 时,CLI 继续用旧目录。可通过 CLI 管理,也可直接编辑文件。 ```bash # LLM 供应商与模型 @@ -393,7 +390,7 @@ octop user list | 命令 | 说明 | |------|------| -| `octop init` | 初始化 `~/.octop/`(数据库、管理员、JWT 密钥) | +| `octop init` | 初始化数据目录(新安装为 `~/.freeos/`;数据库、管理员、JWT 密钥) | | `octop run` | 前台启动 Octop | | `octop service start` | 安装并启动系统服务 | | `octop service stop` | 停止系统服务 | @@ -406,7 +403,7 @@ octop user list | `octop skills` | 按 Agent 启用/禁用 Skill | | `octop plugin` | 安装并管理第三方插件 | | `octop backup` | 导出 / 恢复备份 | -| `octop clean` | 清理 CLI 状态或清空 `~/.octop/` | +| `octop clean` | 清理 CLI 状态或清空当前数据目录 | | `octop update` | 检查并安装更新 | 完整参考:**[docs/cli.md](docs/cli.md)**。 @@ -434,7 +431,7 @@ octop user list ### 📁 数据目录 ``` -~/.octop/ ← 安装与数据根目录 +~/.freeos/ ← 新安装的数据根目录(已有 ~/.octop 且没有 ~/.freeos 时仍用旧目录) ├── config.json # 进程配置(含可选 database 段) ├── octop.db # 默认 SQLite 控制面(用户、Agent、通道、定时任务 …) ├── secrets/ # JWT 密钥、通道 Token @@ -509,10 +506,10 @@ cd dashboard && npx tsc --noEmit ### 🔒 安全与隐私 -- **本地优先**:配置、对话、工作区与凭证均存储在 `~/.octop/`。 +- **本地优先**:配置、对话、工作区与凭证存在本机数据目录。新安装是 `~/.freeos/`;已有的 `~/.octop/` 在没有 `~/.freeos/` 时继续使用。 - **多用户隔离**:JWT 认证,按用户隔离 Agent 与工作区。 - **敏感信息脱敏与工具审批**:离开工作区前自动脱敏敏感数据;高风险工具或 Shell 命令需依据护栏规则显式审批。 -- **工具护栏**:可在 `~/.octop/security/tool_guard/` 编辑 Shell 命令规则。 +- **工具护栏**:可在数据目录的 `security/tool_guard/` 编辑 Shell 命令规则(新安装为 `~/.freeos/security/tool_guard/`)。 - **无厂商锁定**:可自由切换 LLM 供应商、存储后端与 IM 通道。 ### 🤝 参与贡献 diff --git a/desktop/README.md b/desktop/README.md index 03f26972..48ede4e9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -14,7 +14,7 @@ Windows end-user product: **download the NSIS `.exe` → install → open FreeOS ## Data directory -Today the data directory is `~/.octop` (`OCTOP_HOME`). If `FREEOS_HOME` is already set, that path is used. This release does not migrate `~/.octop` to `~/.freeos`. +New desktop installs use `~/.freeos`. Order: `FREEOS_HOME`, then `OCTOP_HOME`, then `~/.freeos`. A leftover `~/.octop` is not the desktop home. CLI installs still keep that legacy tree when `~/.freeos` does not exist yet. This release does not copy one tree into the other. - Green runtime extract → `{home}/portable/` - Organization data (in-host) → `{home}/tenants/`, `{home}/openxyos-mirror/`, `{home}/org-skills/` @@ -42,7 +42,7 @@ The finish page offers **运行 FreeOS** / **Run FreeOS**, checked by default: leave it checked and click Finish to start FreeOS from `$INSTDIR`, or uncheck it and click Finish to close Setup without launching. Setup does **not** auto-launch or auto-close. The launch uses the unelevated -explorer token so the first run does not stamp `%USERPROFILE%\.octop` +explorer token so the first run does not stamp `%USERPROFILE%\.freeos` as High integrity. Silent (`/S`) installs stay headless and do not launch. Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. @@ -54,12 +54,13 @@ Upgrade / reinstall (same version included) refreshes the extracted runtime: handed off to the old process. 2. Setup writes `$INSTDIR\FREEOS_INSTALL_STAMP` (product version + tick count, unique per Setup run) and deletes - `%USERPROFILE%\.octop\portable\FREEOS_STAMP` (also `OCTOP_HOME`, or - `FREEOS_HOME` when that variable is already set). User data - (`octop.db`, settings, chats) is not deleted. + `%USERPROFILE%\.freeos\portable\FREEOS_STAMP` (also `FREEOS_HOME` / + `OCTOP_HOME` when those variables are set). A leftover `~/.octop` + is not the desktop home. User data (`octop.db`, settings, chats) is + not deleted. 3. The next launch compares the install stamp and the zip `FREEOS_STAMP`. Either mismatch re-extracts `packages/` and the embedded dashboard from - the new installer into `~/.octop/portable`, then copies the install + the new installer into `~/.freeos/portable`, then copies the install stamp into that tree. Later opens skip the extract until the next Setup run. If renaming the live `portable` directory to `portable.previous` fails because Windows still has the folder open @@ -108,7 +109,7 @@ the sidecar-era `v0.0.1` Windows builds. **Keeps** -- `%USERPROFILE%\.octop` (or `OCTOP_HOME`; `FREEOS_HOME` only when already set): +- `%USERPROFILE%\.freeos` (or `FREEOS_HOME` / `OCTOP_HOME`; not a leftover `~/.octop`): workspaces, `octop.db`, memories, settings, logs, and the extracted portable runtime under `{home}/portable/` - Documented exceptions **inside** the install directory, if you created them: @@ -196,7 +197,7 @@ cd desktop/src OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev ``` -Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if valid +Without `OCTOP_DESKTOP_URL`, first launch uses `~/.freeos/portable/` if valid **and** it already matches this install (same `FREEOS_STAMP` / install stamp); otherwise it extracts the matching zip shipped with the desktop package (embedded in the Windows and Linux binaries, under `Contents/Resources` on diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 9e8b098a..cbc35039 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -100,7 +100,7 @@ and applies asset packs onto openXYOS-shaped **in-host** surfaces │ in-host org storage + xyos-governance-mcp │ └───────┬───────────────────┬─────────────────┘ │ optional HTTP │ durable pause - │ if opted in │ ~/.octop/governance/ + │ if opted in │ ~/.freeos/governance/ ┌───────▼─────────┐ ┌─────▼─────────────────┐ Optional │ openXYOS :3780 │ │ Pause + audit JSONL │ Node sidecar │ export / sync │ │ Human must approve │ @@ -128,7 +128,7 @@ Agent → tool (outbound / delete / pay / prod) → optional POST {sidecar}/api/governance/validate → no matching allow rule → DEFAULT DENY → write audit.jsonl (and sidecar log when reachable) - → create durable pause ~/.octop/governance/pauses/.json + → create durable pause ~/.freeos/governance/pauses/.json → return execute=false (caller MUST stop) Human → FreeOS/Octop IM /approve or `freeos org governance approve ` → pause status=approved @@ -287,7 +287,7 @@ The Phase A work sits on the existing FreeOS bootstrap: - Octop history as the host (`src/octop/`, `dashboard/`) - openXYOS vendored at `modules/openxyos/` (subtree `@ 1bdba35f`) - Bundled plugin `org-os`, BFF `/api/org-module`, CLI `freeos org` -- Data directory today: `~/.octop` (`OCTOP_HOME`). `FREEOS_HOME` is honored when already set. This release does not migrate `~/.octop` to `~/.freeos`. +- Data directory for a new install: `~/.freeos`. Order matches `PathLayout.from_env`: `FREEOS_HOME`, `OCTOP_HOME`, existing `~/.freeos`, existing `~/.octop` (not for the desktop app), else `~/.freeos`. This release does not copy one tree into the other. - Distribution name is `freeos` (GitHub Release wheels). Imports stay `octop.*`. CLI aliases: `freeos` and `octop`. ### Capability map (host + sidecar) diff --git a/docs/user-guide.html b/docs/user-guide.html index d47006d8..8f864402 100644 --- a/docs/user-guide.html +++ b/docs/user-guide.html @@ -173,7 +173,7 @@

本帮助文档面向最终用户,介绍 安装 → 设置向导 → 配置模型 → 基本使用 的完整流程。 -所有运行时数据默认存放在 ~/.octop/(可通过 OCTOP_HOME 覆盖)。

+新安装的运行时数据在 ~/.freeos/。已有 ~/.octop/ 且还没有 ~/.freeos/ 时,CLI 继续用旧目录。


一、简介

@@ -193,26 +193,26 @@

二、安装 Octop

2.1 环境要求

  • 操作系统:macOS / Linux / Windows。
  • -
  • 无需 预先安装 Python —— 安装脚本会通过 uv 在 ~/.octop/ 下自动创建隔离的 Python 3.12 虚拟环境。
  • +
  • 无需 预先安装 Python —— 本仓库安装脚本会通过 uv 在 ~/.freeos/ 下自动创建隔离的 Python 3.12 虚拟环境(已有 ~/.octop/ 且还没有 ~/.freeos/ 时继续用旧目录)。
  • 需要可访问外网,用于下载安装脚本与依赖。
-

2.2 一键脚本安装(推荐)

-

macOS / Linux

-
curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash
+

2.2 安装

+

推荐:从 GitHub Releases 下载 FreeOS-desktop-*。

+

命令行使用本仓库脚本(当前 develop;打 tag 后由 Release 说明固定标签):

+
curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh | bash
 
-

Windows(PowerShell)

-
irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 | iex
+
irm https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.ps1 | iex
 
-

Windows(cmd) —— 先下载再运行:

-
curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.bat -o install.bat
+
curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.bat -o install.bat
 install.bat
 
+

不要使用腾讯云 COS 上的 …/octop/install.sh(以及 .ps1 / .bat)。那是上游 Octop,默认安装 PyPI 上的 octop,不是 FreeOS。

安装完成后,打开一个新终端 或重新加载 shell 配置,使 PATH 生效:

source ~/.zshrc   # Zsh
 # 或
 source ~/.bashrc  # Bash
 
-

安装脚本会把 octop 命令放入 ~/.octop/bin 并加入 PATH,并在 ~/.octop/venv 创建隔离环境;不会改动系统 Python。

+

新安装会把 octop 放入 ~/.freeos/bin 并加入 PATH,并在 ~/.freeos/venv 创建隔离环境;不会改动系统 Python。

可选附加组件:安装脚本支持通过 --extras 追加能力,例如浏览器自动化 --extras browser、飞书通道 --extras channels-feishu;也可用 --version 指定版本、--mirror <url> 使用国内 PyPI 镜像。更多选项见 scripts/README.md。

@@ -220,7 +220,7 @@

2.3 验证安装

octop --version
 octop run --help
 
-

若提示 command not found: octop,请确认已重新加载 shell 或检查 ~/.octop/bin 是否在 PATH 中。

+

若提示 command not found: octop,请确认已重新加载 shell,或检查 ~/.freeos/bin(旧安装则是 ~/.octop/bin)是否在 PATH 中。

2.4 Docker 安装(生产推荐)

# 构建并后台启动
 docker compose -f docker/docker-compose.yml up -d
@@ -579,8 +579,8 @@ 

七、常见问题

Q:如何修改监听地址让局域网访问? - 启动时:octop run --host 0.0.0.0 --port 8088;或设置环境变量 OCTOP_BIND_HOST=0.0.0.0、OCTOP_PORT=8088。

Q:数据存放在哪里? -- 全部在 ~/.octop/:

-
~/.octop/
+- 新安装在 ~/.freeos/。还没有 ~/.freeos/ 时,已有的 ~/.octop/ 仍给 CLI 使用。桌面端不用残留的 ~/.octop 当家目录。

+
~/.freeos/
 ├── config.json              # 进程级配置(地址、端口、CORS、TLS …)
 ├── octop.db                 # SQLite — 用户、Agent、通道、定时任务 …
 ├── secrets/                 # JWT 密钥、通道 Token
diff --git a/docs/user-guide.md b/docs/user-guide.md
index 96b6cf98..1220316e 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -1,9 +1,9 @@
 # Octop 用户帮助文档
 
-> **Historical vs Current.** 本文沿自 Octop 宿主操作说明(安装、向导、模型、对话)。**Current:** 产品是 [FreeOS](../README.zh-CN.md);权威意图见 [产品契约](product-contract.zh-CN.md)。数据目录今天是 `~/.octop`(`OCTOP_HOME`)。已经设置的 `FREEOS_HOME` 会沿用;这一版不会把 `~/.octop` 迁到 `~/.freeos`。先在本机安顿好;组织能力像工作室里另一间可独立布置的房间。
+> **Historical vs Current.** 本文沿自 Octop 宿主操作说明(安装、向导、模型、对话)。**Current:** 产品是 [FreeOS](../README.zh-CN.md);权威意图见 [产品契约](product-contract.zh-CN.md)。新安装的数据目录是 `~/.freeos`。`FREEOS_HOME` 优先于 `OCTOP_HOME`;还没有 `~/.freeos` 时,已有的 `~/.octop` 仍给 CLI 使用。桌面端不会把残留的 `~/.octop` 当成家目录。先在本机安顿好;组织能力像工作室里另一间可独立布置的房间。
 
 > 本帮助文档面向最终用户,介绍 **安装 → 设置向导 → 配置模型 → 基本使用** 的完整流程。
-> 所有运行时数据默认存放在 `~/.octop/`(可通过 `OCTOP_HOME` 覆盖)。
+> 新安装的运行时数据在 `~/.freeos/`。已有 `~/.octop/` 且还没有 `~/.freeos/` 时,CLI 继续用旧目录。
 
 ---
 
@@ -66,30 +66,32 @@
 ### 2.1 环境要求
 
 - 操作系统:**macOS / Linux / Windows**。
-- **无需** 预先安装 Python —— 安装脚本会通过 [uv](https://docs.astral.sh/uv/) 在 `~/.octop/` 下自动创建隔离的 Python 3.12 虚拟环境。
+- **无需** 预先安装 Python —— 本仓库安装脚本会通过 [uv](https://docs.astral.sh/uv/) 在 `~/.freeos/` 下自动创建隔离的 Python 3.12 虚拟环境(已有 `~/.octop/` 且还没有 `~/.freeos/` 时继续用旧目录)。
 - 需要可访问外网,用于下载安装脚本与依赖。
 
-### 2.2 一键脚本安装(推荐)
+### 2.2 安装
 
-**macOS / Linux**
+**推荐:桌面安装包。** 从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases/latest) 下载 `FreeOS-desktop-*`。
+
+**命令行**使用本仓库脚本。打出版本标签之后,Release 说明会改成固定标签;当前用 `develop`:
 
 ```bash
-curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash
+curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh | bash
 ```
 
-**Windows(PowerShell)**
-
 ```powershell
-irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 | iex
+irm https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.ps1 | iex
 ```
 
-**Windows(cmd)** —— 先下载再运行:
-
 ```bat
-curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.bat -o install.bat
+curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.bat -o install.bat
 install.bat
 ```
 
+已克隆仓库时:`bash scripts/install.sh`。
+
+**不要**使用腾讯云 COS 上的 `…/octop/install.sh`(以及 `.ps1` / `.bat`)。那是上游 Octop,默认安装 PyPI 上的 `octop`,不是 FreeOS。
+
 安装完成后,**打开一个新终端** 或重新加载 shell 配置,使 PATH 生效:
 
 ```bash
@@ -98,9 +100,9 @@ source ~/.zshrc   # Zsh
 source ~/.bashrc  # Bash
 ```
 
-安装脚本会把 `octop` 命令放入 `~/.octop/bin` 并加入 PATH,并在 `~/.octop/venv` 创建隔离环境;**不会改动系统 Python**。
+新安装会把 `octop` 放入 `~/.freeos/bin` 并加入 PATH,并在 `~/.freeos/venv` 创建隔离环境;**不会改动系统 Python**。
 
-> **可选附加组件**:安装脚本默认从 FreeOS GitHub 仓库安装,支持通过 `--extras` 追加能力,例如浏览器自动化 `--extras browser`、飞书通道 `--extras channels-feishu`;也可用 `--version` 指定 GitHub 版本标签、`--mirror ` 使用国内 PyPI 镜像下载 Python 依赖。更多选项见 [scripts/README.md](../scripts/README.md)。
+> **可选附加组件**:本仓库脚本支持 `--extras browser`、`--extras channels-feishu`,也可用 `--version` 指定 GitHub 版本标签。更多选项见 [scripts/README.md](../scripts/README.md)。
 
 ### 2.3 验证安装
 
@@ -109,7 +111,7 @@ octop --version
 octop run --help
 ```
 
-若提示 `command not found: octop`,请确认已重新加载 shell 或检查 `~/.octop/bin` 是否在 PATH 中。
+若提示 `command not found: octop`,请确认已重新加载 shell,或检查 `~/.freeos/bin`(旧安装则是 `~/.octop/bin`)是否在 PATH 中。
 
 ### 2.4 Docker 安装(生产推荐)
 
@@ -452,10 +454,10 @@ Octop 支持两个方向的 ACP 集成:
 - 启动时:`octop run --host 0.0.0.0 --port 8088`;或设置环境变量 `OCTOP_BIND_HOST=0.0.0.0`、`OCTOP_PORT=8088`。
 
 **Q:数据存放在哪里?**
-- 全部在 `~/.octop/`:
+- 新安装在 `~/.freeos/`。还没有 `~/.freeos/` 时,已有的 `~/.octop/` 仍给 CLI 使用。桌面端不用残留的 `~/.octop` 当家目录。
 
 ```
-~/.octop/
+~/.freeos/
 ├── config.json              # 进程级配置(地址、端口、CORS、TLS、database …)
 ├── octop.db                 # 默认 SQLite 控制面 — 用户、Agent、通道、定时任务 …
 ├── secrets/                 # JWT 密钥、通道 Token
diff --git a/scripts/README.md b/scripts/README.md
index 556c04cb..61228121 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -4,15 +4,19 @@
 
 ## 安装 Octop(终端用户)
 
-将 Octop 安装到隔离虚拟环境 `~/.octop/venv`,并创建 `~/.octop/bin/octop` 包装脚本加入 PATH。
+新安装使用 `~/.freeos/venv` 与 `~/.freeos/bin/octop`。还没有 `~/.freeos`、但已经有 `~/.octop` 时,继续用旧目录。
 
-### 远程一键安装(推荐)
+**不要**使用腾讯云 COS 上的 `…/octop/install.*`。那是上游 Octop,默认安装 PyPI `octop`,不是 FreeOS。
+
+桌面用户优先下载 GitHub Releases 的 `FreeOS-desktop-*`。
+
+### 远程脚本(develop;打 tag 后由 Release 说明固定标签)
 
 | 平台 | 命令 |
 |------|------|
-| macOS / Linux | `curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh \| bash` |
-| Windows (PowerShell) | `irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 \| iex` |
-| Windows (cmd) | 下载 `…/octop/install.bat` 后运行,或使用下方本地脚本 |
+| macOS / Linux | `curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.sh \| bash` |
+| Windows (PowerShell) | `irm https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.ps1 \| iex` |
+| Windows (cmd) | 下载 `https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/install.bat` 后运行 |
 
 ### 本地脚本(仓库内)
 
@@ -52,7 +56,8 @@ Windows PowerShell 等价参数:`-Version`、`-FromSource`、`-FromPyPI`、`-S
 
 | 变量 | 说明 |
 |------|------|
-| `OCTOP_HOME` | 安装根目录,默认 `~/.octop` |
+| `FREEOS_HOME` | 安装根目录(优先) |
+| `OCTOP_HOME` | 旧安装根目录覆盖。未设置时:已有 `~/.freeos`,否则已有 `~/.octop`,否则新建 `~/.freeos` |
 | `OCTOP_REPO` | `--from-source` 无本地目录时的 git 克隆地址 |
 | `OCTOP_PYPI_MIRROR` | PyPI 镜像(与 `--mirror` 等效) |
 | `PLAYWRIGHT_DOWNLOAD_HOST` | Playwright 浏览器下载镜像(仅 `--extras browser`) |
diff --git a/scripts/install.bat b/scripts/install.bat
index 3980721e..cbdc1003 100644
--- a/scripts/install.bat
+++ b/scripts/install.bat
@@ -4,7 +4,17 @@ setlocal EnableDelayedExpansion
 REM Octop Installer for Windows (cmd.exe)
 REM Usage: install.bat [-Version X.Y.Z] [-FromSource | -FromPyPI] [-SourceDir DIR] [-Extras browser] [-Help]
 
-if defined OCTOP_HOME (set "OCTOP_HOME=%OCTOP_HOME%") else (set "OCTOP_HOME=%USERPROFILE%\.octop")
+if defined FREEOS_HOME (
+    set "OCTOP_HOME=%FREEOS_HOME%"
+) else if not defined OCTOP_HOME (
+    if exist "%USERPROFILE%\.freeos" (
+        set "OCTOP_HOME=%USERPROFILE%\.freeos"
+    ) else if exist "%USERPROFILE%\.octop" (
+        set "OCTOP_HOME=%USERPROFILE%\.octop"
+    ) else (
+        set "OCTOP_HOME=%USERPROFILE%\.freeos"
+    )
+)
 set "OCTOP_VENV=%OCTOP_HOME%\venv"
 set "OCTOP_BIN=%OCTOP_HOME%\bin"
 set "PYTHON_VERSION=3.12"
@@ -147,8 +157,10 @@ if errorlevel 1 (
 if not exist "%OCTOP_BIN%" mkdir "%OCTOP_BIN%"
 
 echo @echo off > "%OCTOP_BIN%\octop.cmd"
-echo set "OCTOP_HOME=%%OCTOP_HOME%%" >> "%OCTOP_BIN%\octop.cmd"
-echo if "%%OCTOP_HOME%%"=="" set "OCTOP_HOME=%%USERPROFILE%%\.octop" >> "%OCTOP_BIN%\octop.cmd"
+echo if defined FREEOS_HOME set "OCTOP_HOME=%%FREEOS_HOME%%" >> "%OCTOP_BIN%\octop.cmd"
+echo if not defined OCTOP_HOME if exist "%%USERPROFILE%%\.freeos" set "OCTOP_HOME=%%USERPROFILE%%\.freeos" >> "%OCTOP_BIN%\octop.cmd"
+echo if not defined OCTOP_HOME if exist "%%USERPROFILE%%\.octop" set "OCTOP_HOME=%%USERPROFILE%%\.octop" >> "%OCTOP_BIN%\octop.cmd"
+echo if not defined OCTOP_HOME set "OCTOP_HOME=%%USERPROFILE%%\.freeos" >> "%OCTOP_BIN%\octop.cmd"
 echo "%%OCTOP_HOME%%\venv\Scripts\octop.exe" %%* >> "%OCTOP_BIN%\octop.cmd"
 
 set "OCTOP_BIN_FOR_PS=%OCTOP_BIN%"
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 4b891c1d..506f4875 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -2,7 +2,8 @@
 # Usage: irm /install.ps1 | iex
 #    or: .\install.ps1 [-Version X.Y.Z] [-FromSource] [-SourceDir DIR] [-Extras "browser"]
 #
-# Installs Octop into ~/.octop with a uv-managed Python environment.
+# New installs use ~/.freeos. An existing ~/.octop is kept when ~/.freeos
+# does not exist yet (same order as PathLayout.from_env).
 
 & {
 param(
@@ -17,7 +18,20 @@ param(
 
 $ErrorActionPreference = "Stop"
 
-$OctopHome     = if ($env:OCTOP_HOME) { $env:OCTOP_HOME } else { Join-Path $HOME ".octop" }
+function Resolve-FreeOSHome {
+    if ($env:FREEOS_HOME) { return $env:FREEOS_HOME }
+    if ($env:OCTOP_HOME) { return $env:OCTOP_HOME }
+    $freeos = Join-Path $HOME ".freeos"
+    $legacy = Join-Path $HOME ".octop"
+    if (Test-Path $freeos) { return $freeos }
+    foreach ($flag in @($env:OCTOP_DESKTOP, $env:FREEOS_DESKTOP)) {
+        if ($flag -match '^(1|true|yes|on)$') { return $freeos }
+    }
+    if (Test-Path $legacy) { return $legacy }
+    return $freeos
+}
+
+$OctopHome     = Resolve-FreeOSHome
 $OctopVenv     = Join-Path $OctopHome "venv"
 $OctopBin      = Join-Path $OctopHome "bin"
 $PythonVersion = "3.12"
@@ -50,7 +64,10 @@ to install it, or install later from the dashboard. If a system Chrome already
 exists, that download is skipped even with -Extras browser.
 
 Environment:
-  OCTOP_HOME            Installation directory (default: ~/.octop)
+  FREEOS_HOME           Installation directory (preferred)
+  OCTOP_HOME            Legacy installation directory override
+                        New installs use ~/.freeos. An existing ~/.octop is
+                        kept when ~/.freeos does not exist yet.
   OCTOP_REPO            Git clone URL for -FromSource without -SourceDir
 "@
     exit 0
@@ -318,7 +335,19 @@ New-Item -ItemType Directory -Path $OctopBin -Force | Out-Null
 $wrapperPs1 = Join-Path $OctopBin "octop.ps1"
 @'
 $ErrorActionPreference = "Stop"
-$OctopHome = if ($env:OCTOP_HOME) { $env:OCTOP_HOME } else { Join-Path $HOME ".octop" }
+function Resolve-FreeOSHome {
+    if ($env:FREEOS_HOME) { return $env:FREEOS_HOME }
+    if ($env:OCTOP_HOME) { return $env:OCTOP_HOME }
+    $freeos = Join-Path $HOME ".freeos"
+    $legacy = Join-Path $HOME ".octop"
+    if (Test-Path $freeos) { return $freeos }
+    foreach ($flag in @($env:OCTOP_DESKTOP, $env:FREEOS_DESKTOP)) {
+        if ($flag -match '^(1|true|yes|on)$') { return $freeos }
+    }
+    if (Test-Path $legacy) { return $legacy }
+    return $freeos
+}
+$OctopHome = Resolve-FreeOSHome
 $RealBin = Join-Path $OctopHome "venv\Scripts\octop.exe"
 if (-not (Test-Path $RealBin)) {
     Write-Error "Octop environment not found at $OctopHome\venv"
@@ -330,8 +359,17 @@ if (-not (Test-Path $RealBin)) {
 $cmdWrapper = Join-Path $OctopBin "octop.cmd"
 @'
 @echo off
-set "OCTOP_HOME=%OCTOP_HOME%"
-if "%OCTOP_HOME%"=="" set "OCTOP_HOME=%USERPROFILE%\.octop"
+if defined FREEOS_HOME (
+  set "OCTOP_HOME=%FREEOS_HOME%"
+) else if defined OCTOP_HOME (
+  rem keep the legacy override
+) else if exist "%USERPROFILE%\.freeos" (
+  set "OCTOP_HOME=%USERPROFILE%\.freeos"
+) else if exist "%USERPROFILE%\.octop" (
+  set "OCTOP_HOME=%USERPROFILE%\.octop"
+) else (
+  set "OCTOP_HOME=%USERPROFILE%\.freeos"
+)
 set "REAL_BIN=%OCTOP_HOME%\venv\Scripts\octop.exe"
 if not exist "%REAL_BIN%" (
     echo Error: Octop environment not found at %OCTOP_HOME%\venv >&2
diff --git a/scripts/install.sh b/scripts/install.sh
index c90570ca..246a398b 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -4,14 +4,42 @@
 #   或: bash scripts/install.sh --from-pypi   # 从 PyPI 安装 Octop(不含 FreeOS 下游改动)
 #   或: curl -fsSL /install.sh | bash   # 远程安装
 #
-# 将 Octop 安装到 ~/.octop,使用 uv 管理 Python 环境。
+# 新安装目录是 ~/.freeos(已有 ~/.octop 且还没有 ~/.freeos 时继续用旧目录)。
 # 用户无需预先安装 Python — uv 会处理一切。
 # 安装后会尽量把 octop 链接到已在 PATH 中的目录(如 /usr/local/bin),
 # 当前终端无需 source / 重开即可直接使用。
 set -euo pipefail
 
 # ── 默认配置 ──────────────────────────────────────────────────────────────────
-OCTOP_HOME="${OCTOP_HOME:-$HOME/.octop}"
+# Same order as src/octop/infra/utils/paths.py PathLayout.from_env:
+# FREEOS_HOME, OCTOP_HOME, existing ~/.freeos, existing ~/.octop
+# (skipped when the desktop flag is set), else ~/.freeos.
+resolve_install_home() {
+    if [ -n "${FREEOS_HOME:-}" ]; then
+        printf '%s\n' "$FREEOS_HOME"
+        return
+    fi
+    if [ -n "${OCTOP_HOME:-}" ]; then
+        printf '%s\n' "$OCTOP_HOME"
+        return
+    fi
+    if [ -e "$HOME/.freeos" ]; then
+        printf '%s\n' "$HOME/.freeos"
+        return
+    fi
+    _skip_legacy=0
+    for _flag in "${OCTOP_DESKTOP:-}" "${FREEOS_DESKTOP:-}"; do
+        case "$(printf '%s' "$_flag" | tr '[:upper:]' '[:lower:]')" in
+            1|true|yes|on) _skip_legacy=1 ;;
+        esac
+    done
+    if [ "$_skip_legacy" -eq 0 ] && [ -e "$HOME/.octop" ]; then
+        printf '%s\n' "$HOME/.octop"
+        return
+    fi
+    printf '%s\n' "$HOME/.freeos"
+}
+OCTOP_HOME="$(resolve_install_home)"
 OCTOP_VENV="$OCTOP_HOME/venv"
 OCTOP_BIN="$OCTOP_HOME/bin"
 PYTHON_VERSION="3.12"
@@ -94,7 +122,10 @@ Note: Playwright Chromium is not downloaded by default. Pass
   even with --extras browser.
 
 Environment variables:
-  OCTOP_HOME              Install directory (default: ~/.octop)
+  FREEOS_HOME             Install directory (preferred)
+  OCTOP_HOME              Legacy install directory override
+                          New installs use ~/.freeos. An existing ~/.octop is
+                          kept when ~/.freeos does not exist yet.
   OCTOP_PYPI_MIRROR       PyPI mirror URL (same as --mirror)
   OCTOP_REPO              Git URL to clone (used by --from-source with no local dir)
   HARNESS_AGENT_REPO      harness-agent repo (derived from OCTOP_REPO by default)
@@ -104,7 +135,8 @@ Environment variables:
   PLAYWRIGHT_INSTALL_TIMEOUT  Per-mirror download timeout in seconds (default 600)
 
 Details:
-  Everything is installed into an isolated virtualenv (~/.octop/venv), so the
+  Everything is installed into an isolated virtualenv (~/.freeos/venv, or the
+  existing ~/.octop/venv when that legacy tree is still the home), so the
   system Python is untouched. Playwright Chromium is opt-in (--extras browser):
   1. Playwright Chromium browser (skipped if a system browser exists)
   2. System libraries (Linux: apt/dnf/yum/pacman/zypper; only needed for Chromium)
@@ -950,10 +982,35 @@ mkdir -p "$OCTOP_BIN"
 
 cat > "$OCTOP_BIN/octop" << 'WRAPPER'
 #!/usr/bin/env bash
-# Octop CLI 包装脚本 — 委托给 uv 管理的环境。
+# FreeOS CLI wrapper — delegates to the uv-managed environment.
 set -euo pipefail
 
-OCTOP_HOME="${OCTOP_HOME:-$HOME/.octop}"
+resolve_install_home() {
+    if [ -n "${FREEOS_HOME:-}" ]; then
+        printf '%s\n' "$FREEOS_HOME"
+        return
+    fi
+    if [ -n "${OCTOP_HOME:-}" ]; then
+        printf '%s\n' "$OCTOP_HOME"
+        return
+    fi
+    if [ -e "$HOME/.freeos" ]; then
+        printf '%s\n' "$HOME/.freeos"
+        return
+    fi
+    _skip_legacy=0
+    for _flag in "${OCTOP_DESKTOP:-}" "${FREEOS_DESKTOP:-}"; do
+        case "$(printf '%s' "$_flag" | tr '[:upper:]' '[:lower:]')" in
+            1|true|yes|on) _skip_legacy=1 ;;
+        esac
+    done
+    if [ "$_skip_legacy" -eq 0 ] && [ -e "$HOME/.octop" ]; then
+        printf '%s\n' "$HOME/.octop"
+        return
+    fi
+    printf '%s\n' "$HOME/.freeos"
+}
+OCTOP_HOME="$(resolve_install_home)"
 REAL_BIN="$OCTOP_HOME/venv/bin/octop"
 
 if [ ! -x "$REAL_BIN" ]; then

From 26a153a57b25b70a5c9c3c35f286b7b692a96bf3 Mon Sep 17 00:00:00 2001
From: Cursor Agent 
Date: Mon, 26 Oct 2026 15:34:28 +0000
Subject: [PATCH 10/39] chore(release): cut 0.1.0rc1 and publish SHA256SUMS

Point the package version at 0.1.0rc1 so tag v0.1.0rc1 matches
pyproject and stays a GitHub prerelease. Attach SHA256SUMS for desktop
installers, portable zips, wheels, and sdists. Align the employee
password hint and backup home help with the code.

Co-authored-by: haoboy007-sudo 
---
 .github/workflows/octop-desktop.yml           |   8 ++
 .github/workflows/release.yml                 |  10 ++
 CHANGELOG.md                                  |   6 +-
 README.md                                     |   2 +-
 README_CN.md                                  |   4 +-
 dashboard/package-lock.json                   |   4 +-
 dashboard/package.json                        |   2 +-
 docs/adr/001-single-process-model.md          |   2 +-
 docs/cli.md                                   |  18 ++-
 .../frontend/src/pages/EmployeesPage.tsx      |   2 +-
 pyproject.toml                                |   2 +-
 scripts/README.md                             |   4 +-
 scripts/org-export/template/package.json      |   2 +-
 scripts/publish_sha256sums.sh                 |  96 +++++++++++++++
 scripts/release_download_links.py             |   4 +
 scripts/write_sha256sums.py                   | 109 ++++++++++++++++++
 src/octop/__init__.py                         |   2 +-
 src/octop/api/app.py                          |   3 +-
 src/octop/api/routers/backup.py               |   5 +-
 src/octop/cli/commands/backup.py              |  37 +++---
 .../infra/connectors/gateway/protocol.py      |   3 +-
 tests/unit/cli/test_backup_help.py            |  30 +++++
 tests/unit/test_release_download_links.py     |   1 +
 tests/unit/test_write_sha256sums.py           | 102 ++++++++++++++++
 uv.lock                                       |   2 +-
 25 files changed, 420 insertions(+), 40 deletions(-)
 create mode 100644 scripts/publish_sha256sums.sh
 create mode 100644 scripts/write_sha256sums.py
 create mode 100644 tests/unit/cli/test_backup_help.py
 create mode 100644 tests/unit/test_write_sha256sums.py

diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml
index 1c508e60..f2a7420f 100644
--- a/.github/workflows/octop-desktop.yml
+++ b/.github/workflows/octop-desktop.yml
@@ -414,3 +414,11 @@ jobs:
           generate_release_notes: false
           prerelease: ${{ steps.channel.outputs.prerelease }}
           make_latest: ${{ steps.channel.outputs.prerelease == 'true' && 'false' || 'true' }}
+
+      # Refresh SHA256SUMS after desktop installers and portable zips land.
+      # Release publishes the same file for wheel/sdist; this pass adds the
+      # packages just uploaded and any freeos-* assets already on the release.
+      - name: Publish SHA256SUMS
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: bash scripts/publish_sha256sums.sh "${{ steps.rel.outputs.tag }}" release-assets
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 03c305ff..4a7a57d4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -139,6 +139,16 @@ jobs:
           prerelease: ${{ steps.channel.outputs.prerelease }}
           make_latest: ${{ steps.channel.outputs.prerelease == 'true' && 'false' || 'true' }}
 
+      # Hash wheel/sdist plus any desktop installers and portable zips already
+      # attached by Desktop Package. That workflow runs the same script again
+      # after its own upload, so the last publisher leaves one SHA256SUMS that
+      # covers every class. v0.1.0rc1 is a prerelease via release_prerelease.py
+      # (PEP 440 rcN). v0.1.0-rc.1 is not.
+      - name: Publish SHA256SUMS
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: bash scripts/publish_sha256sums.sh "${GITHUB_REF_NAME}" dist
+
   # workflow_run / release:published are unreliable when Release itself was
   # started via GITHUB_TOKEN workflow_dispatch (Auto Tag). Explicitly cascade.
   #
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07eaa8eb..36d8dd5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,9 @@
 
 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。
 
-## [0.1.0] - TBD
+## [0.1.0rc1] - TBD
 
-FreeOS **0.1.0** is an independent semver baseline. It does not track or align with Octop 1.0.x. GitHub Release Python artifacts are `freeos-0.1.0*` (sdist and wheel). The import package stays `octop`. This release does not publish to PyPI, and `pip install octop` installs upstream Octop, not FreeOS. Older Release files may still be named `octop-0.0.10*`.
+FreeOS **0.1.0rc1** is a prerelease of an independent semver baseline. It does not track or align with Octop 1.0.x. The release tag is `v0.1.0rc1`. GitHub Release Python artifacts are `freeos-0.1.0rc1*` (sdist and wheel). The import package stays `octop`. This release does not publish to PyPI, and `pip install octop` installs upstream Octop, not FreeOS. Older Release files may still be named `octop-0.0.10*`.
 
 ### Upgrade from 0.0.10
 
@@ -27,7 +27,7 @@ FreeOS **0.1.0** is an independent semver baseline. It does not track or align w
 - Preserve `config.json` when it is malformed: startup and configuration writers now report the file and parser position, avoid leaking stored credentials, and use atomic replacement instead of silently rebuilding an empty configuration. Absorbed from upstream Octop #730 / #738.
 - Checkpoint the SQLite WAL before the desktop process restarts so an in-app update does not drop the latest writes. Absorbed from upstream Octop #918.
 
-### Not in 0.1.0
+### Not in 0.1.0rc1
 
 Harness migration, cloud bridge default-on, default LDAP/SSO, Teams, and a whole-repo catch-up to Octop are out of scope.
 
diff --git a/README.md b/README.md
index 09bbf633..3b58bd6f 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 diff --git a/README_CN.md b/README_CN.md index d0706fe3..be49352b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,7 +11,7 @@

Python 3.12+ License: MIT - Version + Version 上游:Octop 组织模块:openXYOS Code Style: Ruff @@ -246,7 +246,7 @@ curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/develop/scripts/i 桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。独立商业组织站见 [docs/org-export.md](docs/org-export.md)(`freeos org export-standalone`,默认导出完整 openXYOS 源码树),不是默认安装器里的第二套 Node。 -**不要** `pip install octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS 0.1.0。本仓库发到 GitHub Releases 的 Python 制品是 `freeos--py3-none-any.whl` 和 `freeos-.tar.gz`(导入包仍叫 `octop`)。更早的 Release 里可能还有 `octop-0.0.10*` 文件名,那是旧制品名,不是 PyPI 安装命令。桌面安装包从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载;终端安装用上面的安装脚本(默认拉取 FreeOS GitHub 源码)。只有在你明确要上游 Octop 时才加 `--from-pypi`。可选组件仍通过安装脚本传递,例如 `--extras browser`。 +**不要** `pip install octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS 0.1.0rc1。本仓库发到 GitHub Releases 的 Python 制品是 `freeos--py3-none-any.whl` 和 `freeos-.tar.gz`(导入包仍叫 `octop`)。更早的 Release 里可能还有 `octop-0.0.10*` 文件名,那是旧制品名,不是 PyPI 安装命令。桌面安装包从 [GitHub Releases](https://github.com/XYAIStudio/FreeOS/releases) 下载;终端安装用上面的安装脚本(默认拉取 FreeOS GitHub 源码)。只有在你明确要上游 Octop 时才加 `--from-pypi`。可选组件仍通过安装脚本传递,例如 `--extras browser`。 从源码用 uv 开发时: diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index b53fed4f..c97d3d08 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "octop-dashboard", - "version": "0.1.0", + "version": "0.1.0rc1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "octop-dashboard", - "version": "0.1.0", + "version": "0.1.0rc1", "dependencies": { "@aiden0z/pptx-renderer": "^1.2.4", "@mermaid-js/parser": "^1.1.0", diff --git a/dashboard/package.json b/dashboard/package.json index 9c5cf999..c5ea36b2 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,7 +1,7 @@ { "name": "octop-dashboard", "private": true, - "version": "0.1.0", + "version": "0.1.0rc1", "type": "module", "scripts": { "dev": "vite --host", diff --git a/docs/adr/001-single-process-model.md b/docs/adr/001-single-process-model.md index 09fbfcbd..f2447a21 100644 --- a/docs/adr/001-single-process-model.md +++ b/docs/adr/001-single-process-model.md @@ -15,7 +15,7 @@ Everything runs in a single Python process served by uvicorn. There is no extern ## Rationale -- **Self-hosted target:** Operators install with `pip install octop && octop init`. Adding Redis or a process supervisor doubles the ops burden for the primary audience. Historical note (2026-10): that command describes the upstream-era install path. FreeOS 0.1.0 is installed from GitHub Releases or `scripts/install.sh`, not from the PyPI `octop` package. +- **Self-hosted target:** Operators install with `pip install octop && octop init`. Adding Redis or a process supervisor doubles the ops burden for the primary audience. Historical note (2026-10): that command describes the upstream-era install path. FreeOS 0.1.0rc1 is installed from GitHub Releases or `scripts/install.sh`, not from the PyPI `octop` package. - **Workload profile:** Agent calls are LLM-bound (seconds to minutes of I/O wait). Python's asyncio handles this fan-out efficiently without threads or processes. - **SQLite is sufficient:** Concurrent writes are rare (one writer per agent at a time); WAL mode handles the load. Migrating to Postgres requires only a new `SqlitePool` implementation. - **Restart semantics are simple:** `OctopServer.start()` rebuilds the entire runtime tree from the SQLite file. No external state to reconcile. diff --git a/docs/cli.md b/docs/cli.md index ae5249eb..18713f10 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -60,7 +60,7 @@ Octop commands pick one of three transports: | Layer | When | Login? | Examples | |-------|------|--------|----------| -| **Offline** (local DB only) | Need to read/write `~/.octop` without a running server | No | `init`, `backup`, `plugin`, `agent list`, `chats list/get/create/update/delete`, `cron list`, `user *`, `admin overview/audit`, `models presets/list/active` | +| **Offline** (local DB only) | Need to read/write `~/.freeos` without a running server. An existing `~/.octop` is used when `~/.freeos` is absent, unless `OCTOP_DESKTOP` or `FREEOS_DESKTOP` is set. `FREEOS_HOME` overrides `OCTOP_HOME`. | No | `init`, `backup`, `plugin`, `agent list`, `chats list/get/create/update/delete`, `cron list`, `user *`, `admin overview/audit`, `models presets/list/active` | | **Attach** (HTTP / WS) | Need a live `octop run` process (IM, streams, model pulls) | Yes (`octop user login`) | `chats send/repl`, `channel test/probe`, `models ollama-*`, `skills enable/disable`, `provider test` | | **Embedded** (in-process) | CLI boots `OctopServer` for a single command | No | `octop acp`, `octop chats repl`, `octop chats send` (defaults to embedded), `octop agent create/from-expert/start/stop/reload` | @@ -327,17 +327,23 @@ Commands: ## `octop backup` -Export and restore Octop backups. Works fully offline. +Export and restore FreeOS backups. Works fully offline. + +New installs use `~/.freeos`. When `~/.freeos` does not exist yet, an existing `~/.octop` is used, unless `OCTOP_DESKTOP` or `FREEOS_DESKTOP` is set. `FREEOS_HOME` overrides `OCTOP_HOME`. That is the same order as `PathLayout.from_env`. ``` Usage: octop backup [OPTIONS] COMMAND [ARGS]... - Backup and restore database + local agent workspaces. + Backup and restore the database and local agent workspaces. + + New installs use ~/.freeos. An existing ~/.octop is used when ~/.freeos is + absent, unless OCTOP_DESKTOP or FREEOS_DESKTOP is set. FREEOS_HOME overrides + OCTOP_HOME. Commands: - create Create a backup archive (DB + workspaces + config). - restore Restore a backup archive into ~/.octop. - auto Automatic backup status and one-shot run. + auto Automatic backup status and one-shot run (schedule requires... + create Create a selected-content backup archive. + restore Restore a backup into ~/.freeos, or ~/.octop when that legacy... ``` ### `octop backup auto` diff --git a/modules/openxyos/frontend/src/pages/EmployeesPage.tsx b/modules/openxyos/frontend/src/pages/EmployeesPage.tsx index 957bcc7c..ffa3ea49 100644 --- a/modules/openxyos/frontend/src/pages/EmployeesPage.tsx +++ b/modules/openxyos/frontend/src/pages/EmployeesPage.tsx @@ -618,7 +618,7 @@ function CreateEmployeeModal({ form, departments, skillsList, selectedSkills, on onChange({ ...form, email: e.target.value })} placeholder={t("员工登录邮箱", "Employee sign-in email")} className="w-full px-3 py-2 bg-bg border border-border rounded-lg text-sm focus:outline-none focus:border-primary" /> -

{t("自动创建系统账号,默认密码 emp123456", "A system account is created automatically. Default password: emp123456")}

+

{t("自动创建系统账号,初始密码由系统随机生成。", "A system account is created automatically. The initial password is generated at random.")}

)} diff --git a/pyproject.toml b/pyproject.toml index 37553b83..4dba70a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "freeos" -version = "0.1.0" +version = "0.1.0rc1" description = "FreeOS — self-hosted multi-user multi-agent assistant. GitHub Release artifacts are named freeos-*; the import package stays octop. Not published to PyPI." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/README.md b/scripts/README.md index 61228121..7312c870 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -32,8 +32,8 @@ # 从 FreeOS GitHub 源码安装最新版(默认) bash scripts/install.sh -# 从 FreeOS GitHub v0.1.0 标签安装 -bash scripts/install.sh --version 0.1.0 +# 从 FreeOS GitHub v0.1.0rc1 标签安装 +bash scripts/install.sh --version 0.1.0rc1 # 显式安装 PyPI 上游 Octop(不包含 FreeOS 下游修改) bash scripts/install.sh --from-pypi diff --git a/scripts/org-export/template/package.json b/scripts/org-export/template/package.json index 01e0c0f1..6aadfa9a 100644 --- a/scripts/org-export/template/package.json +++ b/scripts/org-export/template/package.json @@ -1,7 +1,7 @@ { "name": "openxyos-web", "private": true, - "version": "0.1.0", + "version": "0.1.0rc1", "description": "Standalone Organization web generated from dashboard/src/org-ui. Interim API talks to a FreeOS host /api/org-module via proxy.", "type": "module", "scripts": { diff --git a/scripts/publish_sha256sums.sh b/scripts/publish_sha256sums.sh new file mode 100644 index 00000000..79fa6423 --- /dev/null +++ b/scripts/publish_sha256sums.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Upload SHA256SUMS for the desktop installers, portable zips, wheels, and +# sdists on a GitHub Release. Local directories are overlaid on assets already +# attached, so Release and Desktop Package can each refresh the same file. +# +# bash scripts/publish_sha256sums.sh v0.1.0rc1 dist +# bash scripts/publish_sha256sums.sh v0.1.0rc1 release-assets +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: publish_sha256sums.sh [extra_dir ...]" >&2 + exit 2 +fi + +tag="$1" +shift +if [[ "$tag" != v* ]]; then + echo "tag must start with v: $tag" >&2 + exit 2 +fi + +repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT + +EXTRAS=("$@") + +download_pattern_if_present() { + local pattern="$1" + local name saw=0 + for name in "${ASSET_NAMES[@]}"; do + # shellcheck disable=SC2254 + if [[ "$name" == $pattern ]]; then + saw=1 + break + fi + done + if [[ "$saw" -eq 1 ]]; then + gh release download "$tag" --repo "$repo" --pattern "$pattern" --dir "$stage" --skip-existing + fi +} + +stage_assets() { + local extra f base + rm -rf "$stage" + mkdir -p "$stage" + shopt -s nullglob + for extra in "${EXTRAS[@]}"; do + if [[ -d "$extra" ]]; then + for f in "$extra"/*; do + [[ -f "$f" ]] || continue + base="$(basename "$f")" + [[ "$base" == "SHA256SUMS" ]] && continue + if [[ ! -e "$stage/$base" ]]; then + cp "$f" "$stage/$base" + fi + done + fi + done + ASSET_NAMES=() + if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then + local listed + listed="$(gh release view "$tag" --repo "$repo" --json assets --jq '.assets[].name')" + if [[ -n "$listed" ]]; then + mapfile -t ASSET_NAMES <<<"$listed" + fi + download_pattern_if_present 'freeos-*' + download_pattern_if_present 'FreeOS-*' + fi + rm -f "$stage/SHA256SUMS" +} + +refresh_if_release_grew() { + local listed status + listed="$(gh release view "$tag" --repo "$repo" --json assets --jq '.assets[].name')" + set +e + printf '%s\n' "$listed" | python3 "$script_dir/write_sha256sums.py" --missing-from "$stage/SHA256SUMS" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + return 0 + fi + if [[ "$status" -ne 3 ]]; then + exit "$status" + fi + echo "Release gained artifacts after the first hash; rewriting SHA256SUMS" + stage_assets + python3 "$script_dir/write_sha256sums.py" "$stage" + gh release upload "$tag" "$stage/SHA256SUMS" --clobber --repo "$repo" +} + +stage_assets +python3 "$script_dir/write_sha256sums.py" "$stage" +gh release upload "$tag" "$stage/SHA256SUMS" --clobber --repo "$repo" +refresh_if_release_grew diff --git a/scripts/release_download_links.py b/scripts/release_download_links.py index 38c7954a..deb68c8d 100644 --- a/scripts/release_download_links.py +++ b/scripts/release_download_links.py @@ -98,6 +98,10 @@ def render_download_section(raw_version: str) -> str: f"- {md_link('Wheel', tag, f'freeos-{version}-py3-none-any.whl')}", f"- {md_link('sdist', tag, f'freeos-{version}.tar.gz')}", "", + "### Checksums", + "", + f"- {md_link('SHA256SUMS', tag, 'SHA256SUMS')}", + "", ] ) diff --git a/scripts/write_sha256sums.py b/scripts/write_sha256sums.py new file mode 100644 index 00000000..1d8717a6 --- /dev/null +++ b/scripts/write_sha256sums.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Write GNU SHA256SUMS for FreeOS GitHub Release artifacts. + +Covers desktop installers (``FreeOS-desktop-*``), portable zips +(``FreeOS-portable-*.zip``), wheels (``*.whl``), and sdists +(``freeos-*.tar.gz``). ``SHA256SUMS`` itself is not hashed. + + python3 scripts/write_sha256sums.py dist/ +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +from pathlib import Path + + +def is_checksum_target(name: str) -> bool: + """Return whether ``name`` is a desktop installer, zip, wheel, or sdist.""" + if not name or name == "SHA256SUMS" or name.startswith("."): + return False + if name.startswith("FreeOS-desktop-"): + return True + if name.startswith("FreeOS-portable-") and name.endswith(".zip"): + return True + if name.endswith(".whl"): + return True + # Linux desktop packages are FreeOS-desktop-*.tar.gz, already matched. + return name.startswith("freeos-") and name.endswith(".tar.gz") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_sha256sums(directory: Path) -> Path: + """Write ``directory/SHA256SUMS`` and return that path. + + Lines use the GNU coreutils form `` `` so + ``sha256sum -c SHA256SUMS`` can verify them. Names are sorted. + """ + if not directory.is_dir(): + raise SystemExit(f"not a directory: {directory}") + files = sorted( + path for path in directory.iterdir() if path.is_file() and is_checksum_target(path.name) + ) + if not files: + raise SystemExit(f"no desktop, zip, wheel, or sdist artifacts in {directory}") + lines = [f"{_sha256(path)} {path.name}" for path in files] + destination = directory / "SHA256SUMS" + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + return destination + + +def covered_names(checksum_file: Path) -> set[str]: + names: set[str] = set() + text = checksum_file.read_text(encoding="utf-8") + for line in text.splitlines(): + if not line.strip(): + continue + _digest, _sep, name = line.partition(" ") + if name: + names.add(name) + return names + + +def missing_targets(checksum_file: Path, remote_names: list[str]) -> list[str]: + covered = covered_names(checksum_file) + return [name for name in remote_names if is_checksum_target(name) and name not in covered] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Write SHA256SUMS for FreeOS release artifacts.") + parser.add_argument("directory", nargs="?", type=Path) + parser.add_argument( + "--is-target", + metavar="NAME", + help="Exit 0 when NAME should be covered, else 1.", + ) + parser.add_argument( + "--missing-from", + type=Path, + metavar="SHA256SUMS", + help="Read release asset names on stdin. Print targets absent from the file " + "and exit 3 when any are missing.", + ) + args = parser.parse_args(argv) + if args.is_target is not None: + return 0 if is_checksum_target(args.is_target) else 1 + if args.missing_from is not None: + remote = [line.strip() for line in sys.stdin if line.strip()] + missing = missing_targets(args.missing_from, remote) + if missing: + sys.stdout.write("\n".join(missing) + "\n") + return 3 + return 0 + if args.directory is None: + parser.error("directory is required") + write_sha256sums(args.directory) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/octop/__init__.py b/src/octop/__init__.py index d7857b72..ca29e732 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.1.0" +__version__ = "0.1.0rc1" diff --git a/src/octop/api/app.py b/src/octop/api/app.py index e92136dc..f7c9fd15 100644 --- a/src/octop/api/app.py +++ b/src/octop/api/app.py @@ -13,6 +13,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse from scalar_fastapi import get_scalar_api_reference +from octop import __version__ from octop.api.middleware.jwt_auth import install as install_jwt_auth from octop.api.middleware.setup_lockdown import install as install_setup_lockdown from octop.api.openapi_meta import API_DESCRIPTION, OPENAPI_TAGS, configure_openapi @@ -108,7 +109,7 @@ def build_app(server: OctopServer) -> FastAPI: app = FastAPI( title="FreeOS API", - version="0.1.0", + version=__version__, description=API_DESCRIPTION, openapi_url="/api/openapi.json" if enable_api_docs else None, openapi_tags=OPENAPI_TAGS, diff --git a/src/octop/api/routers/backup.py b/src/octop/api/routers/backup.py index b9ce428d..0d86dabe 100644 --- a/src/octop/api/routers/backup.py +++ b/src/octop/api/routers/backup.py @@ -216,8 +216,11 @@ async def list_backups( _: Any = Depends(require_permission("backup")), server: Any = Depends(get_server), ) -> dict[str, Any]: - """List ``.tar.gz`` files in ``~/.octop/backups/``. + """List ``.tar.gz`` files in the data directory's ``backups/``. + New installs use ``~/.freeos/backups/``. An existing ``~/.octop/backups/`` + is used when ``~/.freeos`` is absent, unless ``OCTOP_DESKTOP`` or + ``FREEOS_DESKTOP`` is set. ``FREEOS_HOME`` overrides ``OCTOP_HOME``. Disk/manifest peek runs in a worker thread so the event loop stays free. """ files = await asyncio.to_thread(list_backup_files, server.paths) diff --git a/src/octop/cli/commands/backup.py b/src/octop/cli/commands/backup.py index b90ddb40..64698ec7 100644 --- a/src/octop/cli/commands/backup.py +++ b/src/octop/cli/commands/backup.py @@ -16,6 +16,13 @@ from octop.infra.db.services import build_shared_services from octop.infra.utils.paths import PathLayout +# Same order as PathLayout.from_env. Do not describe ~/.octop as the default. +_HOME_HELP = ( + "Data directory. Precedence: FREEOS_HOME, OCTOP_HOME, an existing ~/.freeos, " + "an existing ~/.octop (skipped when OCTOP_DESKTOP or FREEOS_DESKTOP is set), " + "otherwise ~/.freeos." +) + def _paths(home: Path | None = None) -> PathLayout: return PathLayout(home) if home is not None else PathLayout.from_env() @@ -23,16 +30,19 @@ def _paths(home: Path | None = None) -> PathLayout: @click.group() def backup() -> None: - """Backup and restore database + local agent workspaces.""" + """Backup and restore the database and local agent workspaces. + + New installs use ~/.freeos. An existing ~/.octop is used when ~/.freeos + is absent, unless OCTOP_DESKTOP or FREEOS_DESKTOP is set. FREEOS_HOME + overrides OCTOP_HOME. + """ @backup.command("create") @click.option( "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output .tar.gz path." ) -@click.option( - "--home", type=click.Path(path_type=Path), default=None, help="Octop home (default ~/.octop)." -) +@click.option("--home", type=click.Path(path_type=Path), default=None, help=_HOME_HELP) @click.option("--no-config", is_flag=True, help="Do not include config.json / env.") @click.option("--no-workspaces", is_flag=True, help="Do not include agent workspaces.") @click.option("--no-skill-packages", is_flag=True, help="Do not include global skill packages.") @@ -93,9 +103,7 @@ def auto() -> None: @auto.command("status") -@click.option( - "--home", type=click.Path(path_type=Path), default=None, help="Octop home (default ~/.octop)." -) +@click.option("--home", type=click.Path(path_type=Path), default=None, help=_HOME_HELP) def auto_status(home: Path | None) -> None: """Print automatic backup settings from config.json.""" paths = _paths(home) @@ -114,9 +122,7 @@ def auto_status(home: Path | None) -> None: @auto.command("run") -@click.option( - "--home", type=click.Path(path_type=Path), default=None, help="Octop home (default ~/.octop)." -) +@click.option("--home", type=click.Path(path_type=Path), default=None, help=_HOME_HELP) def auto_run(home: Path | None) -> None: """Create one automatic backup now and apply retention pruning.""" paths = _paths(home) @@ -148,9 +154,7 @@ def auto_run(home: Path | None) -> None: @backup.command("restore") @click.argument("archive", type=click.Path(exists=True, path_type=Path)) -@click.option( - "--home", type=click.Path(path_type=Path), default=None, help="Octop home (default ~/.octop)." -) +@click.option("--home", type=click.Path(path_type=Path), default=None, help=_HOME_HELP) @click.option("--no-config", is_flag=True, default=False, help="Do not restore config.json / env.") @click.option( "--owner-user-id", @@ -167,7 +171,12 @@ def restore( owner_user_id: int | None, yes: bool, ) -> None: - """Restore from a backup archive. Stop ``octop run`` first for a clean restore.""" + """Restore a backup into ~/.freeos, or ~/.octop when that legacy home is still in use. + + Stop ``octop run`` first for a clean restore. An existing ~/.octop is + selected only when ~/.freeos is absent and OCTOP_DESKTOP / FREEOS_DESKTOP + are unset. FREEOS_HOME overrides OCTOP_HOME. + """ if not yes: click.confirm( "This overwrites the database and local workspaces. Continue?", diff --git a/src/octop/infra/connectors/gateway/protocol.py b/src/octop/infra/connectors/gateway/protocol.py index 1f738f68..5b82599e 100644 --- a/src/octop/infra/connectors/gateway/protocol.py +++ b/src/octop/infra/connectors/gateway/protocol.py @@ -5,6 +5,7 @@ import logging from typing import Any +from octop import __version__ from octop.infra.connectors.gateway.registry import call_gateway_tool, mcp_tools_for_kind logger = logging.getLogger(__name__) @@ -28,7 +29,7 @@ def handle_mcp_request( { "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {"tools": {}}, - "serverInfo": {"name": f"octop-{kind}", "version": "0.1.0"}, + "serverInfo": {"name": f"octop-{kind}", "version": __version__}, }, ) diff --git a/tests/unit/cli/test_backup_help.py b/tests/unit/cli/test_backup_help.py new file mode 100644 index 00000000..df8da660 --- /dev/null +++ b/tests/unit/cli/test_backup_help.py @@ -0,0 +1,30 @@ +"""``octop backup --help`` names the FreeOS home and the legacy Octop home.""" + +from __future__ import annotations + +from click.testing import CliRunner + +from octop.cli.main import cli + + +def test_backup_help_uses_freeos_home_and_legacy_octop() -> None: + runner = CliRunner() + result = runner.invoke(cli, ["backup", "--help"]) + assert result.exit_code == 0, result.output + assert "~/.freeos" in result.output + assert "~/.octop" in result.output + assert "default ~/.octop" not in result.output + assert "FREEOS_HOME" in result.output + assert "OCTOP_DESKTOP" in result.output + + create = runner.invoke(cli, ["backup", "create", "--help"]) + assert create.exit_code == 0, create.output + assert "~/.freeos" in create.output + assert "existing ~/.octop" in create.output + assert "default ~/.octop" not in create.output + + restore = runner.invoke(cli, ["backup", "restore", "--help"]) + assert restore.exit_code == 0, restore.output + assert "~/.freeos" in restore.output + assert "~/.octop" in restore.output + assert "default ~/.octop" not in restore.output diff --git a/tests/unit/test_release_download_links.py b/tests/unit/test_release_download_links.py index b52a9aa4..1fd3b3ae 100644 --- a/tests/unit/test_release_download_links.py +++ b/tests/unit/test_release_download_links.py @@ -42,6 +42,7 @@ def test_render_download_section_matches_github_asset_names() -> None: assert f"{base}/FreeOS-fnos-native-0.9.31.fpk" in body assert f"{base}/freeos-0.9.31-py3-none-any.whl" in body assert f"{base}/freeos-0.9.31.tar.gz" in body + assert f"{base}/SHA256SUMS" in body assert "octop-0.9.31" not in body assert "Do not** `pip install octop`" in body assert "## Downloads" in body diff --git a/tests/unit/test_write_sha256sums.py b/tests/unit/test_write_sha256sums.py new file mode 100644 index 00000000..e1c1182b --- /dev/null +++ b/tests/unit/test_write_sha256sums.py @@ -0,0 +1,102 @@ +"""SHA256SUMS generation for FreeOS release artifacts.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import subprocess +from pathlib import Path + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "write_sha256sums.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("write_sha256sums", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_checksum_covers_desktop_zip_wheel_and_sdist(tmp_path: Path) -> None: + mod = _load() + names = { + "FreeOS-desktop-windows-amd64-0.1.0rc1.exe": b"exe", + "FreeOS-desktop-darwin-arm64-0.1.0rc1.dmg": b"dmg", + "FreeOS-desktop-linux-amd64-0.1.0rc1.tar.gz": b"linux-desktop", + "FreeOS-portable-linux-amd64-0.1.0rc1.zip": b"zip", + "freeos-0.1.0rc1-py3-none-any.whl": b"wheel", + "freeos-0.1.0rc1.tar.gz": b"sdist", + "FreeOS-fnos-docker-0.1.0rc1.fpk": b"not-a-target", + "notes.txt": b"nope", + } + for name, payload in names.items(): + (tmp_path / name).write_bytes(payload) + + destination = mod.write_sha256sums(tmp_path) + text = destination.read_text(encoding="utf-8") + lines = text.splitlines() + covered = [line.split(" ", 1)[1] for line in lines] + assert covered == [ + "FreeOS-desktop-darwin-arm64-0.1.0rc1.dmg", + "FreeOS-desktop-linux-amd64-0.1.0rc1.tar.gz", + "FreeOS-desktop-windows-amd64-0.1.0rc1.exe", + "FreeOS-portable-linux-amd64-0.1.0rc1.zip", + "freeos-0.1.0rc1-py3-none-any.whl", + "freeos-0.1.0rc1.tar.gz", + ] + assert "FreeOS-fnos" not in text + assert "notes.txt" not in text + assert text.endswith("\n") + wheel_line = next(line for line in lines if line.endswith("freeos-0.1.0rc1-py3-none-any.whl")) + assert wheel_line.startswith(hashlib.sha256(b"wheel").hexdigest() + " ") + + +def test_empty_directory_is_rejected(tmp_path: Path) -> None: + mod = _load() + try: + mod.write_sha256sums(tmp_path) + except SystemExit as exc: + assert "no desktop" in str(exc) + else: + raise AssertionError("expected SystemExit") + + +def test_missing_from_reports_assets_added_after_the_hash(tmp_path: Path) -> None: + mod = _load() + (tmp_path / "freeos-0.1.0rc1-py3-none-any.whl").write_bytes(b"wheel") + checksum = mod.write_sha256sums(tmp_path) + missing = mod.missing_targets( + checksum, + [ + "freeos-0.1.0rc1-py3-none-any.whl", + "SHA256SUMS", + "FreeOS-desktop-windows-amd64-0.1.0rc1.exe", + "FreeOS-fnos-native-0.1.0rc1.fpk", + ], + ) + assert missing == ["FreeOS-desktop-windows-amd64-0.1.0rc1.exe"] + + +def test_cli_missing_from_exits_3(tmp_path: Path) -> None: + (tmp_path / "freeos-0.1.0rc1.tar.gz").write_bytes(b"sdist") + spec_mod = _load() + checksum = spec_mod.write_sha256sums(tmp_path) + completed = subprocess.run( + ["python3", str(_SCRIPT), "--missing-from", str(checksum)], + input="FreeOS-portable-darwin-arm64-0.1.0rc1.zip\n", + text=True, + capture_output=True, + check=False, + ) + assert completed.returncode == 3 + assert completed.stdout.strip() == "FreeOS-portable-darwin-arm64-0.1.0rc1.zip" + + +def test_release_workflows_publish_sha256sums() -> None: + root = Path(__file__).resolve().parents[2] + release = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") + desktop = (root / ".github/workflows/octop-desktop.yml").read_text(encoding="utf-8") + assert "bash scripts/publish_sha256sums.sh" in release + assert "bash scripts/publish_sha256sums.sh" in desktop + assert 'scripts/release_prerelease.py "${GITHUB_REF_NAME}"' in release diff --git a/uv.lock b/uv.lock index 0662c6ff..d66146e5 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "freeos" -version = "0.1.0" +version = "0.1.0rc1" source = { editable = "." } dependencies = [ { name = "acme" }, From 7d4f7e847f75b8261f030052bec712bde3697fbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 15:48:50 +0000 Subject: [PATCH 11/39] fix(deps): patch npm audit findings without major bumps npm audit fix updates DOMPurify, fast-uri, and serialize-javascript in the dashboard lock, and those plus body-parser, ip-address, multer, and undici in the organization module. All stay on the current major. Co-authored-by: haoboy007-sudo --- dashboard/package-lock.json | 18 ++++++------- modules/openxyos/package-lock.json | 42 +++++++++++++++--------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index c97d3d08..25e4dd81 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -6558,9 +6558,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7118,9 +7118,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -12394,9 +12394,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.5", - "resolved": "https://mirrors.tencent.com/npm/serialize-javascript/-/serialize-javascript-7.0.5.tgz", - "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.4.tgz", + "integrity": "sha512-FqlEuoTq/zRmA4Lg6aOqyH3lCHbUtCB0bmHCeFniX2GBdDqMJM3FwdvEhlE5Pim1dlt2FfBdVPmUNNiGhhKqpw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/modules/openxyos/package-lock.json b/modules/openxyos/package-lock.json index f2561f3b..8d6c8323 100644 --- a/modules/openxyos/package-lock.json +++ b/modules/openxyos/package-lock.json @@ -5779,9 +5779,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -7305,9 +7305,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -8112,9 +8112,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -8989,9 +8989,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", - "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "engines": { "node": ">= 12" @@ -10662,9 +10662,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", - "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz", + "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -12018,9 +12018,9 @@ "license": "MIT" }, "node_modules/serialize-javascript": { - "version": "7.0.7", - "resolved": "https://registry.npmmirror.com/serialize-javascript/-/serialize-javascript-7.0.7.tgz", - "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.4.tgz", + "integrity": "sha512-FqlEuoTq/zRmA4Lg6aOqyH3lCHbUtCB0bmHCeFniX2GBdDqMJM3FwdvEhlE5Pim1dlt2FfBdVPmUNNiGhhKqpw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13088,9 +13088,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { From b8bcd2ad73f813a350ca452c7811c1bf420e1a83 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 15:52:19 +0000 Subject: [PATCH 12/39] fix(release): include FnOS packages in SHA256SUMS Hash FreeOS-fnos-*.fpk with the other release artifacts, and refresh SHA256SUMS after the FnOS workflow attaches those packages. Co-authored-by: haoboy007-sudo --- .github/workflows/fnos-build-fpk.yml | 8 ++++++++ scripts/publish_sha256sums.sh | 9 ++++++--- scripts/write_sha256sums.py | 9 ++++++--- tests/unit/test_write_sha256sums.py | 18 +++++++++++++++--- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 21829870..8c8cf082 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -288,3 +288,11 @@ jobs: make_latest: ${{ steps.channel.outputs.prerelease == 'true' && 'false' || 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Refresh SHA256SUMS after FreeOS-fnos-*.fpk lands. Release and Desktop + # Package publish the same file earlier; this pass adds the FnOS packages + # and keeps installers, zips, wheels, and sdists already on the release. + - name: Publish SHA256SUMS + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/publish_sha256sums.sh "${{ needs.version.outputs.tag }}" dist diff --git a/scripts/publish_sha256sums.sh b/scripts/publish_sha256sums.sh index 79fa6423..a9b2613d 100644 --- a/scripts/publish_sha256sums.sh +++ b/scripts/publish_sha256sums.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Upload SHA256SUMS for the desktop installers, portable zips, wheels, and -# sdists on a GitHub Release. Local directories are overlaid on assets already -# attached, so Release and Desktop Package can each refresh the same file. +# Upload SHA256SUMS for the desktop installers, portable zips, FnOS packages +# (FreeOS-fnos-*.fpk), wheels, and sdists on a GitHub Release. Local +# directories are overlaid on assets already attached, so Release, Desktop +# Package, and FnOS can each refresh the same file. # # bash scripts/publish_sha256sums.sh v0.1.0rc1 dist # bash scripts/publish_sha256sums.sh v0.1.0rc1 release-assets @@ -65,6 +66,8 @@ stage_assets() { if [[ -n "$listed" ]]; then mapfile -t ASSET_NAMES <<<"$listed" fi + # freeos-* is the wheel and sdist. FreeOS-* is desktop installers, + # portable zips, and FnOS packages named FreeOS-fnos-*.fpk. download_pattern_if_present 'freeos-*' download_pattern_if_present 'FreeOS-*' fi diff --git a/scripts/write_sha256sums.py b/scripts/write_sha256sums.py index 1d8717a6..8675f9b2 100644 --- a/scripts/write_sha256sums.py +++ b/scripts/write_sha256sums.py @@ -2,8 +2,9 @@ """Write GNU SHA256SUMS for FreeOS GitHub Release artifacts. Covers desktop installers (``FreeOS-desktop-*``), portable zips -(``FreeOS-portable-*.zip``), wheels (``*.whl``), and sdists -(``freeos-*.tar.gz``). ``SHA256SUMS`` itself is not hashed. +(``FreeOS-portable-*.zip``), FnOS packages (``FreeOS-fnos-*.fpk``), +wheels (``*.whl``), and sdists (``freeos-*.tar.gz``). ``SHA256SUMS`` +itself is not hashed. python3 scripts/write_sha256sums.py dist/ """ @@ -17,13 +18,15 @@ def is_checksum_target(name: str) -> bool: - """Return whether ``name`` is a desktop installer, zip, wheel, or sdist.""" + """Return whether ``name`` is a desktop installer, zip, FnOS package, wheel, or sdist.""" if not name or name == "SHA256SUMS" or name.startswith("."): return False if name.startswith("FreeOS-desktop-"): return True if name.startswith("FreeOS-portable-") and name.endswith(".zip"): return True + if name.startswith("FreeOS-fnos-") and name.endswith(".fpk"): + return True if name.endswith(".whl"): return True # Linux desktop packages are FreeOS-desktop-*.tar.gz, already matched. diff --git a/tests/unit/test_write_sha256sums.py b/tests/unit/test_write_sha256sums.py index e1c1182b..eef08c07 100644 --- a/tests/unit/test_write_sha256sums.py +++ b/tests/unit/test_write_sha256sums.py @@ -27,7 +27,9 @@ def test_checksum_covers_desktop_zip_wheel_and_sdist(tmp_path: Path) -> None: "FreeOS-portable-linux-amd64-0.1.0rc1.zip": b"zip", "freeos-0.1.0rc1-py3-none-any.whl": b"wheel", "freeos-0.1.0rc1.tar.gz": b"sdist", - "FreeOS-fnos-docker-0.1.0rc1.fpk": b"not-a-target", + "FreeOS-fnos-docker-0.1.0rc1.fpk": b"fnos-docker", + "FreeOS-fnos-native-0.1.0rc1.fpk": b"fnos-native", + "Octop-fnos-docker-0.1.0rc1.fpk": b"legacy-name", "notes.txt": b"nope", } for name, payload in names.items(): @@ -41,15 +43,19 @@ def test_checksum_covers_desktop_zip_wheel_and_sdist(tmp_path: Path) -> None: "FreeOS-desktop-darwin-arm64-0.1.0rc1.dmg", "FreeOS-desktop-linux-amd64-0.1.0rc1.tar.gz", "FreeOS-desktop-windows-amd64-0.1.0rc1.exe", + "FreeOS-fnos-docker-0.1.0rc1.fpk", + "FreeOS-fnos-native-0.1.0rc1.fpk", "FreeOS-portable-linux-amd64-0.1.0rc1.zip", "freeos-0.1.0rc1-py3-none-any.whl", "freeos-0.1.0rc1.tar.gz", ] - assert "FreeOS-fnos" not in text + assert "Octop-fnos" not in text assert "notes.txt" not in text assert text.endswith("\n") wheel_line = next(line for line in lines if line.endswith("freeos-0.1.0rc1-py3-none-any.whl")) assert wheel_line.startswith(hashlib.sha256(b"wheel").hexdigest() + " ") + fpk_line = next(line for line in lines if line.endswith("FreeOS-fnos-docker-0.1.0rc1.fpk")) + assert fpk_line.startswith(hashlib.sha256(b"fnos-docker").hexdigest() + " ") def test_empty_directory_is_rejected(tmp_path: Path) -> None: @@ -73,9 +79,13 @@ def test_missing_from_reports_assets_added_after_the_hash(tmp_path: Path) -> Non "SHA256SUMS", "FreeOS-desktop-windows-amd64-0.1.0rc1.exe", "FreeOS-fnos-native-0.1.0rc1.fpk", + "Octop-fnos-docker-0.1.0rc1.fpk", ], ) - assert missing == ["FreeOS-desktop-windows-amd64-0.1.0rc1.exe"] + assert missing == [ + "FreeOS-desktop-windows-amd64-0.1.0rc1.exe", + "FreeOS-fnos-native-0.1.0rc1.fpk", + ] def test_cli_missing_from_exits_3(tmp_path: Path) -> None: @@ -97,6 +107,8 @@ def test_release_workflows_publish_sha256sums() -> None: root = Path(__file__).resolve().parents[2] release = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") desktop = (root / ".github/workflows/octop-desktop.yml").read_text(encoding="utf-8") + fnos = (root / ".github/workflows/fnos-build-fpk.yml").read_text(encoding="utf-8") assert "bash scripts/publish_sha256sums.sh" in release assert "bash scripts/publish_sha256sums.sh" in desktop + assert "bash scripts/publish_sha256sums.sh" in fnos assert 'scripts/release_prerelease.py "${GITHUB_REF_NAME}"' in release From 43997488e4d73b8e0e31f08dccbe814bc812373a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 16:07:06 +0000 Subject: [PATCH 13/39] fix(desktop): map prerelease versions onto numeric Windows resources NSIS VIProductVersion and go-winres file_version reject 0.1.0rc1. Keep the display string, and write 0.1.0.0 for the numeric fields. Co-authored-by: haoboy007-sudo --- .gitignore | 1 + desktop/src/build/stamp_version.py | 59 +++++++++++++++++++++- desktop/src/build/windows/Taskfile.yml | 4 ++ desktop/src/build/windows/nsis/project.nsi | 8 ++- tests/unit/desktop/test_stamp_version.py | 53 +++++++++++++++++++ 5 files changed, 122 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2f413793..b091066d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build/ !desktop/src/build/ !desktop/src/build/** +desktop/src/build/windows/nsis/product_version.nsh dist/ # OS diff --git a/desktop/src/build/stamp_version.py b/desktop/src/build/stamp_version.py index 02ece8fe..91b3391e 100644 --- a/desktop/src/build/stamp_version.py +++ b/desktop/src/build/stamp_version.py @@ -15,6 +15,10 @@ r'(]*\bname="com\.xyaistudio\.freeos"[^>]*\bversion=")[^"]+(")', re.IGNORECASE, ) +# 0.1.0rc1 / 0.1.0a1 / 0.1.0b1 / 0.1.0.dev1 → release core 0.1.0. +_PEP440_CORE = re.compile(r"^(?P\d+(?:\.\d+)*)(?:(?:a|b|rc)\d+|\.dev\d+)?$") +# wails_tools.nsh fallback INFO_PRODUCTVERSION, plus the extra ".0" NSIS appends. +_NSIS_DEV_FALLBACK = "0.0.2.0" def should_stamp(version: str) -> bool: @@ -32,6 +36,44 @@ def four_part_version(version: str) -> str | None: return ".".join(parts[:4]) +def release_core(version: str) -> str | None: + """Numeric core of a PEP 440 version, dropping a/b/rc/.dev suffixes.""" + if not should_stamp(version): + return None + match = _PEP440_CORE.fullmatch(version) + if match is None: + return None + return match.group("core") + + +def nsis_vi_version(version: str) -> str: + """Four integers for NSIS VIProductVersion / VIFileVersion. + + ``0.1.0`` stays ``0.1.0.0``. ``0.1.0rc1`` uses the same numeric fields; + the installer display string keeps the prerelease text separately. + ``dev`` matches the local makensis fallback in ``wails_tools.nsh``. + """ + if not should_stamp(version): + return _NSIS_DEV_FALLBACK + core = release_core(version) + if core is None: + raise SystemExit(f"cannot map {version!r} to an NSIS version") + dotted = four_part_version(core) + if dotted is None: + raise SystemExit(f"cannot map {version!r} to an NSIS version") + return dotted + + +def write_nsis_numeric(version: str, dest: Path) -> None: + """Write the ``!define`` included by ``project.nsi``.""" + numeric = nsis_vi_version(version) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text( + f'!define INFO_PRODUCTVERSION_NUMERIC "{numeric}"\n', + encoding="utf-8", + ) + + def stamp_plist(path: Path, version: str) -> None: if not should_stamp(version): return @@ -48,7 +90,10 @@ def stamp_info_json(src: Path, dest: Path, version: str) -> None: if not should_stamp(version): return data = json.loads(dest.read_text(encoding="utf-8")) - data["fixed"]["file_version"] = version + # go-winres parses file_version as integers. A prerelease string such as + # 0.1.0rc1 stays on the ProductVersion display field only. + file_version = version if four_part_version(version) is not None else nsis_vi_version(version) + data["fixed"]["file_version"] = file_version data["info"]["0000"]["ProductVersion"] = version dest.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8") @@ -60,6 +105,9 @@ def stamp_manifest(src: Path, dest: Path, version: str) -> None: if not should_stamp(version): return dotted = four_part_version(version) + if dotted is None: + core = release_core(version) + dotted = four_part_version(core) if core is not None else None if dotted is None: return text = dest.read_text(encoding="utf-8") @@ -87,11 +135,20 @@ def main(argv: list[str] | None = None) -> int: man_p.add_argument("dest", type=Path) man_p.add_argument("version") + nsis_p = sub.add_parser( + "nsis-numeric", + help="Write product_version.nsh with a four-integer VIProductVersion", + ) + nsis_p.add_argument("version") + nsis_p.add_argument("dest", type=Path) + args = parser.parse_args(argv) if args.cmd == "plist": stamp_plist(args.path, args.version) elif args.cmd == "json": stamp_info_json(args.src, args.dest, args.version) + elif args.cmd == "nsis-numeric": + write_nsis_numeric(args.version, args.dest) else: stamp_manifest(args.src, args.dest, args.version) return 0 diff --git a/desktop/src/build/windows/Taskfile.yml b/desktop/src/build/windows/Taskfile.yml index 9f7a33f6..6288510b 100644 --- a/desktop/src/build/windows/Taskfile.yml +++ b/desktop/src/build/windows/Taskfile.yml @@ -115,6 +115,10 @@ tasks: powershell -NoProfile -Command "Remove-Item -Force '{{.ROOT_DIR}}/{{.ARCHIVE}}' -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Force '{{.ROOT_DIR}}/{{.BIN_DIR}}' | Out-Null" + - cmd: python ../../stamp_version.py nsis-numeric "{{.VERSION}}" product_version.nsh + platforms: [windows] + - cmd: python3 ../../stamp_version.py nsis-numeric "{{.VERSION}}" product_version.nsh + platforms: [linux, darwin] - | {{if eq OS "windows"}} makensis -INPUTCHARSET UTF8 {{.VERSION_NSIS_DEFINE}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" -DINSTALLER_OUTFILE="{{.ROOT_DIR}}\{{.ARCHIVE}}" project.nsi diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index c9385c06..4125fc5c 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -11,12 +11,16 @@ # "è¿è¡Œ FreeOS". Keep this file UTF-8 with BOM. !include "wails_tools.nsh" +# Written by `stamp_version.py nsis-numeric` before makensis. +# VIProductVersion / VIFileVersion must be four integers (0.1.0rc1 -> 0.1.0.0). +# String keys below keep the full product version, including a prerelease suffix. +!include "product_version.nsh" SetCompressor /SOLID lzma # The version information for this two must consist of 4 parts -VIProductVersion "${INFO_PRODUCTVERSION}.0" -VIFileVersion "${INFO_PRODUCTVERSION}.0" +VIProductVersion "${INFO_PRODUCTVERSION_NUMERIC}" +VIFileVersion "${INFO_PRODUCTVERSION_NUMERIC}" VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" diff --git a/tests/unit/desktop/test_stamp_version.py b/tests/unit/desktop/test_stamp_version.py index 00562856..e59fd8fb 100644 --- a/tests/unit/desktop/test_stamp_version.py +++ b/tests/unit/desktop/test_stamp_version.py @@ -65,6 +65,59 @@ def test_four_part_version_pads_semver() -> None: assert stamp_version.four_part_version("1.2.3") == "1.2.3.0" assert stamp_version.four_part_version("1.2.3.4") == "1.2.3.4" assert stamp_version.four_part_version("dev") is None + assert stamp_version.four_part_version("0.1.0rc1") is None + + +def test_nsis_vi_version_strips_prerelease() -> None: + assert stamp_version.nsis_vi_version("0.1.0") == "0.1.0.0" + assert stamp_version.nsis_vi_version("0.1.0rc1") == "0.1.0.0" + assert stamp_version.nsis_vi_version("1.2.3a1") == "1.2.3.0" + assert stamp_version.nsis_vi_version("1.2.3.dev2") == "1.2.3.0" + assert stamp_version.nsis_vi_version("dev") == "0.0.2.0" + + +def test_stamp_info_json_keeps_prerelease_on_display_string(tmp_path: Path) -> None: + src = ( + Path(__file__).resolve().parents[3] / "desktop" / "src" / "build" / "windows" / "info.json" + ) + dest = tmp_path / "info.generated.json" + stamp_version.stamp_info_json(src, dest, "0.1.0rc1") + data = json.loads(dest.read_text(encoding="utf-8")) + assert data["fixed"]["file_version"] == "0.1.0.0" + assert data["info"]["0000"]["ProductVersion"] == "0.1.0rc1" + + +def test_stamp_manifest_uses_numeric_core_for_prerelease(tmp_path: Path) -> None: + src = ( + Path(__file__).resolve().parents[3] + / "desktop" + / "src" + / "build" + / "windows" + / "wails.exe.manifest" + ) + dest = tmp_path / "wails.exe.generated.manifest" + stamp_version.stamp_manifest(src, dest, "0.1.0rc1") + text = dest.read_text(encoding="utf-8") + assert 'name="com.xyaistudio.freeos" version="0.1.0.0"' in text + + +def test_write_nsis_numeric_and_installer_wiring(tmp_path: Path) -> None: + dest = tmp_path / "product_version.nsh" + stamp_version.write_nsis_numeric("0.1.0rc1", dest) + assert dest.read_text(encoding="utf-8") == '!define INFO_PRODUCTVERSION_NUMERIC "0.1.0.0"\n' + root = Path(__file__).resolve().parents[3] + nsi = (root / "desktop" / "src" / "build" / "windows" / "nsis" / "project.nsi").read_text( + encoding="utf-8" + ) + task = (root / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( + encoding="utf-8" + ) + assert 'VIProductVersion "${INFO_PRODUCTVERSION_NUMERIC}"' in nsi + assert 'VIFileVersion "${INFO_PRODUCTVERSION_NUMERIC}"' in nsi + assert 'VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"' in nsi + assert "stamp_version.py nsis-numeric" in task + assert '!include "product_version.nsh"' in nsi def test_stamp_manifest_sets_octop_identity_only(tmp_path: Path) -> None: From 5ca8e23bec1098d9cf7a19d9bbcc90a1490e5b95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 16:22:24 +0000 Subject: [PATCH 14/39] fix(release): sort SHA256SUMS by filename on every platform Windows pathlib orders paths case-insensitively, so freeos-* sorted ahead of FreeOS-*. Compare the filename string instead. Co-authored-by: haoboy007-sudo --- scripts/write_sha256sums.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/write_sha256sums.py b/scripts/write_sha256sums.py index 8675f9b2..8c5e9a5b 100644 --- a/scripts/write_sha256sums.py +++ b/scripts/write_sha256sums.py @@ -49,8 +49,11 @@ def write_sha256sums(directory: Path) -> Path: """ if not directory.is_dir(): raise SystemExit(f"not a directory: {directory}") + # Sort by the filename string. pathlib.Path ordering on Windows is + # case-insensitive, which would place freeos-* ahead of FreeOS-*. files = sorted( - path for path in directory.iterdir() if path.is_file() and is_checksum_target(path.name) + (path for path in directory.iterdir() if path.is_file() and is_checksum_target(path.name)), + key=lambda path: path.name, ) if not files: raise SystemExit(f"no desktop, zip, wheel, or sdist artifacts in {directory}") From b6f4b9c12b562943a6396dfb231929b870649bb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 00:56:13 +0000 Subject: [PATCH 15/39] fix(install): label user-visible install logs as FreeOS Operators still saw [octop] and "Installing Octop" while installing FreeOS. Log prefixes, success lines, and install prompts now say FreeOS. Paths, the octop CLI, and notes about upstream PyPI Octop stay unchanged. Co-authored-by: haoboy007-sudo --- desktop/package-dev.sh | 6 +-- docker/docker-entrypoint.sh | 8 ++-- fnos/README.md | 4 +- fnos/docker/app/ui/config | 2 +- fnos/docker/cmd/config_callback | 10 ++--- fnos/docker/cmd/config_init | 4 +- fnos/docker/cmd/install_callback | 6 +-- fnos/docker/cmd/install_init | 4 +- fnos/docker/cmd/uninstall_callback | 2 +- fnos/docker/cmd/uninstall_init | 2 +- fnos/docker/manifest | 2 +- fnos/docker/wizard/install | 2 +- fnos/native/app/bin/octop | 16 ++++---- fnos/native/app/bin/octop-cli | 4 +- fnos/native/app/ui/config | 2 +- fnos/native/cmd/config_callback | 12 +++--- fnos/native/cmd/config_init | 4 +- fnos/native/cmd/install_callback | 24 +++++------ fnos/native/cmd/install_init | 4 +- fnos/native/cmd/main | 12 +++--- fnos/native/cmd/uninstall_callback | 6 +-- fnos/native/cmd/uninstall_init | 2 +- fnos/native/manifest | 4 +- fnos/native/wizard/install | 2 +- scripts/fnos/common.sh | 16 ++++---- scripts/install-octop.sh | 30 +++++++------- scripts/install.bat | 66 +++++++++++++++--------------- scripts/install.ps1 | 20 ++++----- scripts/install.sh | 26 ++++++------ src/octop/infra/setup/service.py | 2 +- 30 files changed, 152 insertions(+), 152 deletions(-) diff --git a/desktop/package-dev.sh b/desktop/package-dev.sh index 75c6507b..a4f9ab61 100644 --- a/desktop/package-dev.sh +++ b/desktop/package-dev.sh @@ -42,7 +42,7 @@ cleanup() { trap cleanup EXIT INT TERM if curl -sf -o /dev/null --max-time 1 "$HEALTH"; then - echo "Octop already listening at ${URL}; stop it first so this script can own the process." >&2 + echo "FreeOS already listening at ${URL}; stop it first so this script can own the process." >&2 exit 1 fi @@ -58,14 +58,14 @@ for _ in $(seq 1 60); do break fi if ! kill -0 "$octop_pid" 2>/dev/null; then - echo "Octop 进程在就绪前退出了,请查看上方日志。" >&2 + echo "FreeOS 进程在就绪前退出了,请查看上方日志。" >&2 exit 1 fi sleep 0.5 done if ! curl -sf -o /dev/null --max-time 1 "$HEALTH"; then - echo "Octop 未在 30 秒内就绪(${URL})。" >&2 + echo "FreeOS 未在 30 秒内就绪(${URL})。" >&2 echo "请查看上方 octop run 的输出;常见原因:端口被占用、依赖缺失,或服务启动失败。" >&2 exit 1 fi diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index b212c4bf..a5de348c 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# Octop 容器入口脚本 +# FreeOS 容器入口脚本 # # 环境变量: # HOME — 必须为 /data,使 ~/.octop 映射到数据卷 @@ -47,7 +47,7 @@ octop_random_password() { DEFAULT_PASSWORD="${OCTOP_DEFAULT_PASSWORD:-}" if [ ! -f "$DB_FILE" ]; then - echo "[entrypoint] 首次启动,正在初始化 Octop..." + echo "[entrypoint] 首次启动,正在初始化 FreeOS..." if [ -z "$DEFAULT_PASSWORD" ]; then DEFAULT_PASSWORD="$(octop_random_password)" @@ -69,7 +69,7 @@ if [ ! -f "$DB_FILE" ]; then fi cat > "$CREDENTIAL_FILE" << EOF -Octop Login Credential +FreeOS Login Credential ====================== URL: http://:${PORT} Username: ${ADMIN_USERNAME} @@ -87,7 +87,7 @@ EOF fi if [ $# -eq 0 ]; then - echo "[entrypoint] 正在启动 Octop,端口 $PORT..." + echo "[entrypoint] 正在启动 FreeOS,端口 $PORT..." exec octop run --host 0.0.0.0 --port "$PORT" fi diff --git a/fnos/README.md b/fnos/README.md index b8483e5a..03190861 100644 --- a/fnos/README.md +++ b/fnos/README.md @@ -11,7 +11,7 @@ 生成的 / 设置的密码通过以下两种方式**双保险**送达用户: -- **应用「设置」窗口**:飞牛应用中心 → Octop → 设置,窗口顶部直接显示当前管理员账号与密码,也可在此随时改密(保持不变 / 随机生成 / 自己设置); +- **应用「设置」窗口**:飞牛应用中心 → FreeOS → 设置,窗口顶部直接显示当前管理员账号与密码,也可在此随时改密(保持不变 / 随机生成 / 自己设置); - **回落备份文件**:数据共享目录下的 `octop-login.txt`(本地版在 data-share 共享目录,Docker 版在 `/var/apps/octop/share/octop/data/`),永久保留并随改密同步更新。 > 若你在 Web 控制台「头像菜单 → 修改密码」中改过密码,则以 Web 密码为准(`octop-login.txt` 与「设置」窗口显示的是安装/配置阶段写入的凭据)。 @@ -107,7 +107,7 @@ bash scripts/build-fpk.sh native # 仅本地版 → dist/FreeOS-fnos-na - 想用 Docker 跑、主机已装 Docker → 选 `FreeOS-fnos-docker-.fpk` - 不想依赖 Docker、希望自带运行时原生运行 → 选 `FreeOS-fnos-native-.fpk` 2. 安装向导中设置管理员账号/密码、日志级别、LLM 密钥(可选)。 -3. 安装完成后桌面出现「Octop AI 助手」图标,浏览器打开 `http://<设备IP>:8088`。 +3. 安装完成后桌面出现「FreeOS」图标,浏览器打开 `http://<设备IP>:8088`。 4. Docker 版镜像首次会从 GHCR `ghcr.io/xyaistudio/freeos:latest` 拉取;请确保该包为 Public(首次推送后可在 GitHub Packages 设置)。本地版无需联网拉镜像。Playwright Chromium 不预装,需要远程浏览器时在控制台按需安装。 > 服务端口固定为 `8088`(飞牛端口映射与桌面图标均据此)。`desktop` 桌面控制已在镜像中默认安装。 diff --git a/fnos/docker/app/ui/config b/fnos/docker/app/ui/config index a8260225..c6f9903f 100644 --- a/fnos/docker/app/ui/config +++ b/fnos/docker/app/ui/config @@ -1,7 +1,7 @@ { ".url": { "octop.Application": { - "title": "OCTOP", + "title": "FreeOS", "icon": "images/icon_{0}.png", "type": "iframe", "protocol": "http", diff --git a/fnos/docker/cmd/config_callback b/fnos/docker/cmd/config_callback index b12b018c..2e9d13c0 100644 --- a/fnos/docker/cmd/config_callback +++ b/fnos/docker/cmd/config_callback @@ -42,8 +42,8 @@ OPENAI_NEW="$(octop_sanitize_value "${wizard_openai_api_key:-}")" DASHSCOPE_NEW="$(octop_sanitize_value "${wizard_dashscope_api_key:-}")" fail_config() { - echo "[octop] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop] $1" >&2 || true + echo "[freeos] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] $1" >&2 || true exit 1 } @@ -95,9 +95,9 @@ DASHSCOPE="$CUR_DASH" if [ "$PASS_CHANGED" = "1" ]; then if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$CONTAINER" \ && docker exec "$CONTAINER" octop user passwd "$ADMIN_USER" --password "$ADMIN_PASS" > "$LOG" 2>&1; then - echo "[octop] 已通过官方 CLI(docker exec octop octop user passwd)修改管理员密码" > "$LOG" 2>&1 || true + echo "[freeos] 已通过官方 CLI(docker exec octop octop user passwd)修改管理员密码" > "$LOG" 2>&1 || true else - echo "[octop] 提示:容器未运行或在线改密未成功,密码将写入 .env;重建容器/重新初始化时生效。" > "$LOG" 2>&1 || true + echo "[freeos] 提示:容器未运行或在线改密未成功,密码将写入 .env;重建容器/重新初始化时生效。" > "$LOG" 2>&1 || true fi fi @@ -113,7 +113,7 @@ octop_env_set "$ENV_FILE" OCTOP_ADMIN_DISPLAY_NAME "Admin" octop_env_set "$ENV_FILE" OPENAI_API_KEY "$OPENAI" octop_env_set "$ENV_FILE" DASHSCOPE_API_KEY "$DASHSCOPE" -echo "[octop] 配置已更新: $ENV_FILE" > "$LOG" 2>&1 || true +echo "[freeos] 配置已更新: $ENV_FILE" > "$LOG" 2>&1 || true # 凭据回落保存 + 「设置」窗口当前密码显示刷新 octop_write_login_file "$DATA_DIR" "$ADMIN_USER" "$ADMIN_PASS" 8088 diff --git a/fnos/docker/cmd/config_init b/fnos/docker/cmd/config_init index f293bcf2..2d8147af 100644 --- a/fnos/docker/cmd/config_init +++ b/fnos/docker/cmd/config_init @@ -11,8 +11,8 @@ _OCTOP_COMMON="$(dirname "$0")/common.sh" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" fail_config() { - echo "[octop] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop] $1" >&2 || true + echo "[freeos] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] $1" >&2 || true exit 1 } diff --git a/fnos/docker/cmd/install_callback b/fnos/docker/cmd/install_callback index b77b6dc9..47504d75 100644 --- a/fnos/docker/cmd/install_callback +++ b/fnos/docker/cmd/install_callback @@ -31,8 +31,8 @@ DATA_DIR="/var/apps/octop/share/octop/data" mkdir -p "$DATA_DIR" fail_install() { - echo "[octop] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop] $1" >&2 || true + echo "[freeos] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] $1" >&2 || true exit 1 } @@ -86,7 +86,7 @@ esac } > "$ENV_FILE" chmod 600 "$ENV_FILE" -echo "[octop] 已写入配置: $ENV_FILE" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true +echo "[freeos] 已写入配置: $ENV_FILE" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true # --------------------------------------------------------------------------- # 凭据回落保存(octop-login.txt)+ 「设置」窗口当前密码显示渲染 diff --git a/fnos/docker/cmd/install_init b/fnos/docker/cmd/install_init index 8a034a6d..cf444907 100644 --- a/fnos/docker/cmd/install_init +++ b/fnos/docker/cmd/install_init @@ -11,8 +11,8 @@ _OCTOP_COMMON="$(dirname "$0")/common.sh" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" fail_init() { - echo "[octop] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop] $1" >&2 || true + echo "[freeos] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] $1" >&2 || true exit 1 } diff --git a/fnos/docker/cmd/uninstall_callback b/fnos/docker/cmd/uninstall_callback index 3f94e8b3..521c4e4d 100644 --- a/fnos/docker/cmd/uninstall_callback +++ b/fnos/docker/cmd/uninstall_callback @@ -7,7 +7,7 @@ if [ "${wizard_delete_data:-no}" = "yes" ]; then DATA_DIR="/vol1/@appshare/octop/data" if [ -d "$DATA_DIR" ]; then rm -rf "$DATA_DIR" 2>/dev/null || true - echo "[octop] 已删除用户数据: $DATA_DIR" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true + echo "[freeos] 已删除用户数据: $DATA_DIR" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true fi fi diff --git a/fnos/docker/cmd/uninstall_init b/fnos/docker/cmd/uninstall_init index e77ba25e..2bc7933c 100644 --- a/fnos/docker/cmd/uninstall_init +++ b/fnos/docker/cmd/uninstall_init @@ -11,7 +11,7 @@ set -euo pipefail _OCTOP_COMMON=""$(dirname "$0")/common.sh"" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" -log() { echo "[octop] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true; } +log() { echo "[freeos] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true; } diff --git a/fnos/docker/manifest b/fnos/docker/manifest index 6514abf4..ca776c87 100644 --- a/fnos/docker/manifest +++ b/fnos/docker/manifest @@ -1,6 +1,6 @@ appname=octop version=0.0.1 -display_name=OCTOP +display_name=FreeOS desc=FreeOS 自托管 AI 工作室(Octop 数据面 + openXYOS 组织能力)。支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
在应用中心点击「打开」按钮即可进入 Web 控制台,默认地址 http://设备IP:8088。
管理员账号与密码在安装向导中设置:可自己输入密码,也可使用自动生成的随机密码;当前密码可随时在应用的「设置」窗口中查看或修改,数据目录下的 octop-login.txt 同步保留一份备份。

首次登录后:
请在控制台「设置」中配置 LLM 模型、IM 渠道、远程桌面、浏览器等组件。

注意:Docker 版通过容器部署,会自动从 ghcr.io/xyaistudio/freeos:latest 拉取镜像运行。默认零 Node;组织能力在宿主内。 source=thirdparty platform=all diff --git a/fnos/docker/wizard/install b/fnos/docker/wizard/install index 6f9f1814..c61d1452 100644 --- a/fnos/docker/wizard/install +++ b/fnos/docker/wizard/install @@ -4,7 +4,7 @@ "items": [ { "type": "tips", - "helpText": "Octop(Docker 版) — 腾讯开源的多用户多智能体 AI 助手平台。
请为管理员设置登录账号与密码。
密码要求:至少 8 位,且必须同时包含字母和数字。" + "helpText": "FreeOS(Docker 版) — 自托管 AI 工作室。
请为管理员设置登录账号与密码。
密码要求:至少 8 位,且必须同时包含字母和数字。" }, { "type": "text", diff --git a/fnos/native/app/bin/octop b/fnos/native/app/bin/octop index a80f0196..109024ae 100644 --- a/fnos/native/app/bin/octop +++ b/fnos/native/app/bin/octop @@ -43,7 +43,7 @@ export OCTOP_DATA_DIR="$DATA_DIR" PY="$(find_python312)" || { - echo "[octop-native] 错误:未找到 Python 3.12。" >&2 + echo "[freeos-native] 错误:未找到 Python 3.12。" >&2 echo "请先前往飞牛应用中心安装「Python 3.12」开发工具,再重新安装本应用。" >&2 exit 1 } @@ -69,9 +69,9 @@ export OCTOP_FPK_SITE_PACKAGES="$SITE_PACKAGES" # 兜底:若包内 site-packages 中未检测到 octop(升级异常 / 损坏等), # 用内置核心 whl 就地离线安装,避免用户联网下载。 if ! "$PY" -c "import octop" >/dev/null 2>&1; then - echo "[octop-native] 未检测到已安装的 Octop,正在用内置核心 whl 离线安装 ..." + echo "[freeos-native] 未检测到已安装的 FreeOS,正在用内置核心 whl 离线安装 ..." "$PY" -m pip install --no-cache --no-index --find-links "$SITE_PACKAGES" "$SITE_PACKAGES/octop.whl" >/dev/null 2>&1 || { - echo "[octop-native] 离线安装失败,请检查网络或手动执行:$PY -m pip install $SITE_PACKAGES/octop.whl" >&2 + echo "[freeos-native] 离线安装失败,请检查网络或手动执行:$PY -m pip install $SITE_PACKAGES/octop.whl" >&2 exit 1 } fi @@ -93,23 +93,23 @@ octop_cmd() { # 报 "password is too common")→ 换随机强密码重试一次; # 3) 重试后同步回写 .env 与 octop-login.txt 回落备份,保证凭据一致、用户不丢密码。 if [ ! -f "$OCTOP_HOME/octop.db" ]; then - echo "[octop-native] 首次启动,正在初始化管理员账号 ..." + echo "[freeos-native] 首次启动,正在初始化管理员账号 ..." INIT_PW="${OCTOP_DEFAULT_PASSWORD:-}" if [ -z "$INIT_PW" ]; then INIT_PW="$(octop_generate_password 16)" - echo "[octop-native] .env 未配置初始密码,已自动生成随机密码。" + echo "[freeos-native] .env 未配置初始密码,已自动生成随机密码。" fi if ! octop_cmd init --yes \ --admin-username "${OCTOP_ADMIN_USERNAME:-admin}" \ --admin-password "$INIT_PW" \ ${OCTOP_ADMIN_DISPLAY_NAME:+--admin-display-name "$OCTOP_ADMIN_DISPLAY_NAME"}; then - echo "[octop-native] 初始密码未通过应用密码策略(过弱或过于常见),改用随机密码重试 ..." + echo "[freeos-native] 初始密码未通过应用密码策略(过弱或过于常见),改用随机密码重试 ..." INIT_PW="$(octop_generate_password 16)" octop_cmd init --yes \ --admin-username "${OCTOP_ADMIN_USERNAME:-admin}" \ --admin-password "$INIT_PW" \ ${OCTOP_ADMIN_DISPLAY_NAME:+--admin-display-name "$OCTOP_ADMIN_DISPLAY_NAME"} || { - echo "[octop-native] 错误:管理员账号初始化失败,请查看上方日志。" >&2 + echo "[freeos-native] 错误:管理员账号初始化失败,请查看上方日志。" >&2 exit 1 } fi @@ -122,5 +122,5 @@ if [ ! -f "$OCTOP_HOME/octop.db" ]; then octop_write_login_file "$DATA_DIR" "${OCTOP_ADMIN_USERNAME:-admin}" "$INIT_PW" "${OCTOP_PORT:-8089}" fi -echo "[octop-native] 正在启动 Octop,$OCTOP_BIND_HOST:$OCTOP_PORT ..." +echo "[freeos-native] 正在启动 FreeOS,$OCTOP_BIND_HOST:$OCTOP_PORT ..." exec "$PY" -m octop.cli.main run --host "$OCTOP_BIND_HOST" --port "$OCTOP_PORT" --log-level "${OCTOP_LOG_LEVEL}" diff --git a/fnos/native/app/bin/octop-cli b/fnos/native/app/bin/octop-cli index a3b50e29..891c9d14 100755 --- a/fnos/native/app/bin/octop-cli +++ b/fnos/native/app/bin/octop-cli @@ -77,9 +77,9 @@ export OCTOP_FPK_SITE_PACKAGES="$SITE_PACKAGES" # 兜底:若包内 octop 未就绪,用内置核心 whl 离线安装(避免联网) if ! "$PY" -c "import octop" >/dev/null 2>&1; then - echo "[octop] 未检测到已安装的 Octop,正在用内置核心 whl 离线安装 ..." + echo "[freeos] 未检测到已安装的 FreeOS,正在用内置核心 whl 离线安装 ..." "$PY" -m pip install --no-cache --no-index --find-links "$SITE_PACKAGES" "$SITE_PACKAGES/octop.whl" >/dev/null 2>&1 || { - echo "[octop] 离线安装失败,请检查网络或手动执行:$PY -m pip install $SITE_PACKAGES/octop.whl" >&2 + echo "[freeos] 离线安装失败,请检查网络或手动执行:$PY -m pip install $SITE_PACKAGES/octop.whl" >&2 exit 1 } fi diff --git a/fnos/native/app/ui/config b/fnos/native/app/ui/config index 7e9a915f..d7474173 100644 --- a/fnos/native/app/ui/config +++ b/fnos/native/app/ui/config @@ -1,7 +1,7 @@ { ".url": { "octop-native.Application": { - "title": "OCTOP", + "title": "FreeOS", "icon": "images/icon_{0}.png", "type": "iframe", "protocol": "http", diff --git a/fnos/native/cmd/config_callback b/fnos/native/cmd/config_callback index 2fa1f965..e29c6a02 100644 --- a/fnos/native/cmd/config_callback +++ b/fnos/native/cmd/config_callback @@ -44,8 +44,8 @@ OPENAI_NEW="$(octop_sanitize_value "${wizard_openai_api_key:-}")" DASHSCOPE_NEW="$(octop_sanitize_value "${wizard_dashscope_api_key:-}")" fail_config() { - echo "[octop-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop-native] $1" >&2 || true + echo "[freeos-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] $1" >&2 || true exit 1 } @@ -98,12 +98,12 @@ DASHSCOPE="$CUR_DASH" OCTOP_HOME="${DATA_DIR}/.octop" MAIN="$APPDEST/cmd/main" if [ "$PASS_CHANGED" = "1" ] && [ -f "$OCTOP_HOME/octop.db" ]; then - echo "[octop-native] 检测到已初始化实例,先停止服务再离线修改管理员密码 ..." > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 检测到已初始化实例,先停止服务再离线修改管理员密码 ..." > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true [ -x "$MAIN" ] && "$MAIN" stop >/dev/null 2>&1 || true if "$APPDEST/bin/octop-cli" user passwd "$ADMIN_USER" --password "$ADMIN_PASS" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1; then - echo "[octop-native] 已通过官方 CLI(octop user passwd)修改管理员密码" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 已通过官方 CLI(octop user passwd)修改管理员密码" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true else - echo "[octop-native] 警告:离线改密未成功(octop user passwd 执行失败),将仅更新 .env;首次重启 init / 重新安装时生效。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 警告:离线改密未成功(octop user passwd 执行失败),将仅更新 .env;首次重启 init / 重新安装时生效。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true fi [ -x "$MAIN" ] && "$MAIN" start >/dev/null 2>&1 || true fi @@ -120,7 +120,7 @@ octop_env_set "$ENV_FILE" OCTOP_ADMIN_DISPLAY_NAME "Admin" octop_env_set "$ENV_FILE" OPENAI_API_KEY "$OPENAI" octop_env_set "$ENV_FILE" DASHSCOPE_API_KEY "$DASHSCOPE" -echo "[octop-native] 已更新配置: $ENV_FILE" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true +echo "[freeos-native] 已更新配置: $ENV_FILE" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true # 凭据回落保存 + 「设置」窗口当前密码显示刷新 octop_write_login_file "$DATA_DIR" "$ADMIN_USER" "$ADMIN_PASS" 8089 diff --git a/fnos/native/cmd/config_init b/fnos/native/cmd/config_init index 99c0cc2e..e9db7ac4 100644 --- a/fnos/native/cmd/config_init +++ b/fnos/native/cmd/config_init @@ -11,8 +11,8 @@ _OCTOP_COMMON="$(dirname "$0")/common.sh" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" fail_config() { - echo "[octop-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop-native] $1" >&2 || true + echo "[freeos-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] $1" >&2 || true exit 1 } diff --git a/fnos/native/cmd/install_callback b/fnos/native/cmd/install_callback index 8a87e520..a6be267b 100644 --- a/fnos/native/cmd/install_callback +++ b/fnos/native/cmd/install_callback @@ -39,8 +39,8 @@ fi mkdir -p "$DATA_DIR" fail_install() { - echo "[octop-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop-native] $1" >&2 || true + echo "[freeos-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] $1" >&2 || true exit 1 } @@ -94,7 +94,7 @@ esac } > "$ENV_FILE" chmod 600 "$ENV_FILE" -echo "[octop-native] 已写入配置: $ENV_FILE" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true +echo "[freeos-native] 已写入配置: $ENV_FILE" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true # --------------------------------------------------------------------------- # 管理员凭据回落保存(octop-login.txt,数据共享目录内,升级保留)+「设置」窗口渲染。 @@ -115,12 +115,12 @@ fix_ownership_and_perms "$PKGVAR" "$ENV_FILE" # 校验 Python 3.12 是否存在 if ! PY="$(find_python312)"; then - echo "[octop-native] 错误:飞牛系统中未找到 Python 3.12。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "请先前往飞牛应用中心 → 开发工具 → 安装「Python 3.12」,然后重新安装 Octop 本地版。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 错误:飞牛系统中未找到 Python 3.12。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "请先前往飞牛应用中心 → 开发工具 → 安装「Python 3.12」,然后重新安装 FreeOS 本地版。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true exit 1 fi -echo "[octop-native] 已检测到 Python 3.12: $PY" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true +echo "[freeos-native] 已检测到 Python 3.12: $PY" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true # --------------------------------------------------------------------------- # 让 Octop 本地版“开箱即用”: @@ -138,7 +138,7 @@ OCTOP_USER="octop-native" # 设置 octop-native 登录密码为向导管理员密码(若账户不存在则跳过) if id "$OCTOP_USER" >/dev/null 2>&1; then echo "${OCTOP_USER}:${ADMIN_PASS}" | chpasswd 2>/dev/null || true - echo "[octop-native] 已为 ${OCTOP_USER} 设置登录密码(与管理员密码一致)" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 已为 ${OCTOP_USER} 设置登录密码(与管理员密码一致)" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true fi # 写入免密码 sudo 规则并做语法校验。 @@ -157,11 +157,11 @@ EOF chmod 0440 "$SUDOERS_FILE" if command -v visudo >/dev/null 2>&1; then if ! visudo -c -f "$SUDOERS_FILE" >/dev/null 2>&1; then - echo "[octop-native] 错误:写入的 sudoers 语法校验失败,请检查 ${SUDOERS_FILE}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 错误:写入的 sudoers 语法校验失败,请检查 ${SUDOERS_FILE}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true exit 1 fi fi -echo "[octop-native] 已配置 ${OCTOP_USER} 免密码 sudo" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true +echo "[freeos-native] 已配置 ${OCTOP_USER} 免密码 sudo" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true # --------------------------------------------------------------------------- # 将官方 octop CLI 暴露到 PATH,让用户安装后像官方一样直接用 `octop` 命令 @@ -176,12 +176,12 @@ if [ -f "$CLI_SRC" ]; then chmod +x "$CLI_SRC" 2>/dev/null || true ln -sf "$CLI_SRC" /usr/local/bin/octop-cli ln -sf "$CLI_SRC" /usr/local/bin/octop - echo "[octop-native] 已注册命令:octop / octop-cli -> $CLI_SRC" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 已注册命令:octop / octop-cli -> $CLI_SRC" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true # CLI 冒烟验证(不阻塞安装:失败只告警,首次启动后仍可用) if "$CLI_SRC" version >/dev/null 2>&1; then - echo "[octop-native] CLI 冒烟验证通过:octop version 可正常执行" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] CLI 冒烟验证通过:octop version 可正常执行" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true else - echo "[octop-native] 警告:CLI 冒烟验证未通过(octop version 执行失败);首次启动会自动用内置 whl 离线安装,之后 CLI 即可用。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 警告:CLI 冒烟验证未通过(octop version 执行失败);首次启动会自动用内置 whl 离线安装,之后 CLI 即可用。" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true fi fi diff --git a/fnos/native/cmd/install_init b/fnos/native/cmd/install_init index 7a6da50e..7d56963f 100644 --- a/fnos/native/cmd/install_init +++ b/fnos/native/cmd/install_init @@ -11,8 +11,8 @@ _OCTOP_COMMON="$(dirname "$0")/common.sh" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" fail_init() { - echo "[octop-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true - echo "[octop-native] $1" >&2 || true + echo "[freeos-native] $1" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] $1" >&2 || true exit 1 } diff --git a/fnos/native/cmd/main b/fnos/native/cmd/main index ac446f39..964a04bf 100644 --- a/fnos/native/cmd/main +++ b/fnos/native/cmd/main @@ -21,7 +21,7 @@ log_msg() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "${LOG_FILE}"; } start_process() { if status; then return 0; fi mkdir -p "${PKGVAR}" - log_msg "Starting Octop (native) on port ${PORT} ..." + log_msg "Starting FreeOS (native) on port ${PORT} ..." # 以最低权限用户 octop-native 运行 Octop 服务(安全:web 服务与工作台终端 # 不以 root 身份暴露)。若该用户不存在(个别 FnOS 版本在 run-as=root 时不 @@ -32,9 +32,9 @@ start_process() { local -a RUN_AS=() if id octop-native >/dev/null 2>&1 && command -v runuser >/dev/null 2>&1; then RUN_AS=(runuser -u octop-native --) - log_msg "将以 octop-native 低权限用户运行 Octop 服务" + log_msg "将以 octop-native 低权限用户运行 FreeOS 服务" else - log_msg "octop-native 用户不可用,回退以当前身份运行 Octop 服务" + log_msg "octop-native 用户不可用,回退以当前身份运行 FreeOS 服务" fi # 启动 Octop 监听所有接口(供飞牛直接通过端口访问) @@ -47,19 +47,19 @@ start_process() { break fi if ! kill -0 "$octop_pid" 2>/dev/null; then - log_msg "Octop process exited early" + log_msg "FreeOS process exited early" return 1 fi sleep 1 done printf "%s" "$octop_pid" > "${PID_FILE}" - log_msg "Octop pid=$octop_pid" + log_msg "FreeOS pid=$octop_pid" return 0 } stop_process() { - log_msg "Stopping Octop (native) ..." + log_msg "Stopping FreeOS (native) ..." if [ -r "${PID_FILE}" ]; then pid=$(head -n 1 "${PID_FILE}" | tr -d '[:space:]') diff --git a/fnos/native/cmd/uninstall_callback b/fnos/native/cmd/uninstall_callback index 08bc1fd1..c3f7f702 100644 --- a/fnos/native/cmd/uninstall_callback +++ b/fnos/native/cmd/uninstall_callback @@ -7,11 +7,11 @@ set -uo pipefail # 清理安装时写入的免密码 sudo 规则(install_callback 以 root 写入,这里同样以 # root 删除;卸载后不应再保留对 octop-native 的 NOPASSWD 授权)。 rm -f /etc/sudoers.d/octop-native 2>/dev/null || true -echo "[octop-native] 已移除免密码 sudo 规则" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true +echo "[freeos-native] 已移除免密码 sudo 规则" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true # 移除安装时注册到 PATH 的 octop / octop-cli 命令 rm -f /usr/local/bin/octop /usr/local/bin/octop-cli 2>/dev/null || true -echo "[octop-native] 已移除命令:octop / octop-cli" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true +echo "[freeos-native] 已移除命令:octop / octop-cli" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true # 停止并禁用可能残留的虚拟桌面 systemd 单元(若用户卸载前未在 UI 内卸载桌面)。 if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then @@ -26,7 +26,7 @@ if [ "${wizard_delete_data:-no}" = "yes" ]; then DATA_DIR="${DATA_DIR%%:*}" if [ -n "$DATA_DIR" ] && [ -d "$DATA_DIR" ]; then rm -rf "$DATA_DIR" 2>/dev/null || true - echo "[octop-native] 已删除用户数据: $DATA_DIR" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos-native] 已删除用户数据: $DATA_DIR" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true fi fi diff --git a/fnos/native/cmd/uninstall_init b/fnos/native/cmd/uninstall_init index 0986c983..03780c27 100644 --- a/fnos/native/cmd/uninstall_init +++ b/fnos/native/cmd/uninstall_init @@ -14,7 +14,7 @@ _OCTOP_COMMON=""$(dirname "$0")/common.sh"" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" LOG="${TRIM_TEMP_LOGFILE:-/dev/null}" -log() { echo "[octop-native] $1" > "$LOG" 2>/dev/null || true; } +log() { echo "[freeos-native] $1" > "$LOG" 2>/dev/null || true; } # 卸载时一并释放 Octop 端口(8088=Docker 版,8089=本地版),杀掉残留/孤儿进程,避免卸载不干净。 diff --git a/fnos/native/manifest b/fnos/native/manifest index 24795f34..35ec0e14 100644 --- a/fnos/native/manifest +++ b/fnos/native/manifest @@ -1,7 +1,7 @@ appname=octop-native version=0.0.1 -display_name=OCTOP -desc=腾讯开源的多用户多智能体 AI 助手平台,支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
在应用中心点击「打开」按钮即可进入 Web 控制台,默认地址 http://设备IP:8089。
管理员账号与密码在安装向导中设置:可自己输入密码,也可使用自动生成的随机密码;当前密码可随时在应用的「设置」窗口中查看或修改,数据目录下的 octop-login.txt 同步保留一份备份。

命令行管理:
支持官方 octop CLI(octop provider list / agent list / user list / skills / backup 等,详见 octop --help)。

首次登录后:
请在控制台「设置」中配置 LLM 模型、IM 渠道、远程桌面、浏览器等组件。

注意:本地版直接运行在飞牛主机上,安装前会自动关联安装「Python 3.12」开发工具。 +display_name=FreeOS +desc=FreeOS 自托管 AI 工作室,支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
在应用中心点击「打开」按钮即可进入 Web 控制台,默认地址 http://设备IP:8089。
管理员账号与密码在安装向导中设置:可自己输入密码,也可使用自动生成的随机密码;当前密码可随时在应用的「设置」窗口中查看或修改,数据目录下的 octop-login.txt 同步保留一份备份。

命令行管理:
支持官方 octop CLI(octop provider list / agent list / user list / skills / backup 等,详见 octop --help)。

首次登录后:
请在控制台「设置」中配置 LLM 模型、IM 渠道、远程桌面、浏览器等组件。

注意:本地版直接运行在飞牛主机上,安装前会自动关联安装「Python 3.12」开发工具。 source=thirdparty platform=all maintainer=TencentCloud OrcaKit diff --git a/fnos/native/wizard/install b/fnos/native/wizard/install index 7e4c8404..b698ad13 100644 --- a/fnos/native/wizard/install +++ b/fnos/native/wizard/install @@ -4,7 +4,7 @@ "items": [ { "type": "tips", - "helpText": "Octop — 腾讯开源的多用户多智能体 AI 助手平台。
请为管理员设置登录账号与密码。
密码要求:至少 8 位,且必须同时包含字母和数字。" + "helpText": "FreeOS — 自托管 AI 工作室。
请为管理员设置登录账号与密码。
密码要求:至少 8 位,且必须同时包含字母和数字。" }, { "type": "text", diff --git a/scripts/fnos/common.sh b/scripts/fnos/common.sh index 7c2d0811..993c9064 100644 --- a/scripts/fnos/common.sh +++ b/scripts/fnos/common.sh @@ -27,7 +27,7 @@ free_octop_ports() { for pid in $pids; do [ -n "$pid" ] || continue if kill -TERM "$pid" 2>/dev/null; then - echo "[octop] 已发送 TERM 给占用 ${port} 的进程 ${pid}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true + echo "[freeos] 已发送 TERM 给占用 ${port} 的进程 ${pid}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true fi done sleep 1 @@ -35,7 +35,7 @@ free_octop_ports() { [ -n "$pid" ] || continue if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null || true - echo "[octop] 已强制 KILL 占用 ${port} 的进程 ${pid}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true + echo "[freeos] 已强制 KILL 占用 ${port} 的进程 ${pid}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>/dev/null || true fi done done @@ -45,7 +45,7 @@ free_octop_ports() { for pat in "$appdir/bin/octop" "$appdir/app/bin/octop"; do pids="$(pgrep -f -- "$pat" 2>/dev/null | tr '\n' ' ')" || true [ -z "$pids" ] && continue - echo "[octop] 发现本应用残留服务进程($pat): $pids,准备清理" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] 发现本应用残留服务进程($pat): $pids,准备清理" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true for pid in $pids; do kill -TERM "$pid" 2>/dev/null || true done @@ -53,7 +53,7 @@ free_octop_ports() { for pid in $pids; do if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null || true - echo "[octop] 已强制 KILL 残留服务进程 ${pid}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] 已强制 KILL 残留服务进程 ${pid}" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true fi done done @@ -69,7 +69,7 @@ fix_ownership_and_perms() { local pkgvar="$1" envfile="$2" local octop_user="octop-native" id "$octop_user" >/dev/null 2>&1 || { - echo "[octop] 警告:${octop_user} 账户不存在,跳过数据目录 chown(服务将回退以 root 运行)" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] 警告:${octop_user} 账户不存在,跳过数据目录 chown(服务将回退以 root 运行)" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true return 0 } @@ -100,7 +100,7 @@ fix_ownership_and_perms() { fi fi - echo "[octop] 已修正数据目录/.env 属主与权限(${octop_user}:${octop_user})" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true + echo "[freeos] 已修正数据目录/.env 属主与权限(${octop_user}:${octop_user})" > "${TRIM_TEMP_LOGFILE:-/dev/null}" 2>&1 || true } # --------------------------------------------------------------------------- @@ -235,7 +235,7 @@ octop_write_login_file() { file="${data_dir}/octop-login.txt" cat > "$file" << EOF ========================================================== - Octop 管理员登录信息(请妥善保管,勿泄露给他人) + FreeOS 管理员登录信息(请妥善保管,勿泄露给他人) ========================================================== 访问地址:http://<飞牛IP>:${port} 管理员账号:${username} @@ -243,7 +243,7 @@ octop_write_login_file() { 说明: - 本文件是管理员密码的备份副本,每次通过应用「设置」修改密码后会自动更新。 -- 也可以随时在飞牛应用中心 → Octop → 「设置」窗口中直接查看当前密码。 +- 也可以随时在飞牛应用中心 → FreeOS → 「设置」窗口中直接查看当前密码。 - 建议登录后在 Web 控制台「头像菜单 → 修改密码」中更换为自己的密码。 EOF chmod 600 "$file" 2>/dev/null || true diff --git a/scripts/install-octop.sh b/scripts/install-octop.sh index 17703d34..01946ca3 100644 --- a/scripts/install-octop.sh +++ b/scripts/install-octop.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Octop 安装脚本 (macOS / Linux) +# FreeOS 安装脚本 (macOS / Linux) # 用法: bash scripts/install-octop.sh # 从 FreeOS GitHub 源码安装(默认) # 或: bash scripts/install-octop.sh --from-pypi # 从 PyPI 安装 Octop(不含 FreeOS 下游改动) # 或: curl -fsSL /install.sh | bash # 远程安装 # -# 将 Octop 安装到 ~/.octop,使用 uv 管理 Python 环境。 +# 将 FreeOS 安装到 ~/.octop,使用 uv 管理 Python 环境。 # 用户无需预先安装 Python — uv 会处理一切。 # 安装后会尽量把 octop 链接到已在 PATH 中的目录(如 /usr/local/bin), # 当前终端无需 source / 重开即可直接使用。 @@ -49,9 +49,9 @@ else BOLD="" GREEN="" YELLOW="" RED="" RESET="" fi -info() { printf "${GREEN}[octop]${RESET} %s\n" "$*"; } -warn() { printf "${YELLOW}[octop]${RESET} %s\n" "$*"; } -error() { printf "${RED}[octop]${RESET} %s\n" "$*" >&2; } +info() { printf "${GREEN}[freeos]${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}[freeos]${RESET} %s\n" "$*"; } +error() { printf "${RED}[freeos]${RESET} %s\n" "$*" >&2; } die() { error "$@"; exit 1; } # ── 解析参数 ────────────────────────────────────────────────────────────────── @@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do PYPI_MIRROR="$2"; shift 2 ;; -h|--help) cat </dev/null || die "git is required to clone the repos. Install git or use --from-pypi." mkdir -p "$workdir" - info "Cloning harness-agent / harness-gateway / Octop sources..." + info "Cloning harness-agent / harness-gateway / FreeOS sources..." git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/harness-agent" git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/harness-gateway" git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/harness-browser" @@ -692,7 +692,7 @@ _pin_mcp_compat || true _verify_install [ -x "$OCTOP_VENV/bin/octop" ] || die "Install failed: octop CLI not found in the virtualenv" -info "Octop installed successfully" +info "FreeOS installed successfully" if [ "$_CONSOLE_AVAILABLE" = 0 ]; then CONSOLE_CHECK="$("$OCTOP_VENV/bin/python" -c "import importlib.resources, octop; p=importlib.resources.files('octop')/'dashboard'/'index.html'; print('yes' if p.is_file() else 'no')" 2>/dev/null || echo 'no')" @@ -915,14 +915,14 @@ mkdir -p "$OCTOP_BIN" cat > "$OCTOP_BIN/octop" << 'WRAPPER' #!/usr/bin/env bash -# Octop CLI 包装脚本 — 委托给 uv 管理的环境。 +# FreeOS CLI 包装脚本 — 委托给 uv 管理的环境。 set -euo pipefail OCTOP_HOME="${OCTOP_HOME:-$HOME/.octop}" REAL_BIN="$OCTOP_HOME/venv/bin/octop" if [ ! -x "$REAL_BIN" ]; then - echo "Error: Octop environment not found in $OCTOP_HOME/venv" >&2 + echo "Error: FreeOS environment not found in $OCTOP_HOME/venv" >&2 echo "Please re-run the installer" >&2 exit 1 fi @@ -1023,7 +1023,7 @@ add_to_profile() { return 0 fi if [ -f "$profile" ] || [ "$create" = "create" ]; then - printf '\n# Octop\n%s\n' "$PATH_ENTRY" >> "$profile" + printf '\n# FreeOS\n%s\n' "$PATH_ENTRY" >> "$profile" info "Updated $profile" return 0 fi @@ -1033,7 +1033,7 @@ add_to_profile() { _write_profile_d() { # CentOS / Ubuntu 登录 shell 会加载 /etc/profile.d/*.sh local dest="/etc/profile.d/octop.sh" - local content="# Octop CLI + local content="# FreeOS CLI export PATH=\"${OCTOP_BIN}:\$PATH\" " if [ -d /etc/profile.d ]; then @@ -1074,7 +1074,7 @@ export PATH="$OCTOP_BIN:$PATH" # ── 完成 ────────────────────────────────────────────────────────────────────── echo "" -printf "${GREEN}${BOLD}Octop installed successfully!${RESET}\n" +printf "${GREEN}${BOLD}FreeOS installed successfully!${RESET}\n" echo "" printf " Location: ${BOLD}%s${RESET}\n" "$OCTOP_HOME" printf " Python: ${BOLD}%s${RESET}\n" "$("$OCTOP_VENV/bin/python" --version 2>&1)" diff --git a/scripts/install.bat b/scripts/install.bat index cbdc1003..c407f18c 100644 --- a/scripts/install.bat +++ b/scripts/install.bat @@ -1,7 +1,7 @@ @echo off setlocal EnableDelayedExpansion -REM Octop Installer for Windows (cmd.exe) +REM FreeOS Installer for Windows (cmd.exe) REM Usage: install.bat [-Version X.Y.Z] [-FromSource | -FromPyPI] [-SourceDir DIR] [-Extras browser] [-Help] if defined FREEOS_HOME ( @@ -40,7 +40,7 @@ shift goto :parse_args :show_help -echo Octop Installer for Windows +echo FreeOS Installer for Windows echo. echo Usage: install.bat [OPTIONS] echo -Version ^ Install specific version @@ -55,25 +55,25 @@ echo to install it, or install later from the dashboard. exit /b 0 :done_args -echo [octop] Installing Octop into %OCTOP_HOME% +echo [freeos] Installing FreeOS into %OCTOP_HOME% call :ensure_uv if errorlevel 1 exit /b 1 if exist "%OCTOP_VENV%" ( - echo [octop] Existing environment found, upgrading... + echo [freeos] Existing environment found, upgrading... ) else ( - echo [octop] Creating Python %PYTHON_VERSION% environment... + echo [freeos] Creating Python %PYTHON_VERSION% environment... ) uv venv "%OCTOP_VENV%" --python %PYTHON_VERSION% --quiet --clear -if errorlevel 1 (echo [octop] ERROR: Failed to create venv & exit /b 1) +if errorlevel 1 (echo [freeos] ERROR: Failed to create venv & exit /b 1) set "VENV_PYTHON=%OCTOP_VENV%\Scripts\python.exe" -if not exist "%VENV_PYTHON%" (echo [octop] ERROR: venv python missing & exit /b 1) +if not exist "%VENV_PYTHON%" (echo [freeos] ERROR: venv python missing & exit /b 1) for /f "delims=" %%v in ('"%VENV_PYTHON%" --version 2^>^&1') do set "PY_VERSION=%%v" -echo [octop] Python environment ready (%PY_VERSION%) +echo [freeos] Python environment ready (%PY_VERSION%) set "EXTRAS_SUFFIX=[browser]" if defined ARG_EXTRAS set "EXTRAS_SUFFIX=[browser,%ARG_EXTRAS%]" @@ -88,15 +88,15 @@ goto :install_from_git :install_from_local for %%I in ("%ARG_SOURCE_DIR%") do set "ARG_SOURCE_DIR=%%~fI" -echo [octop] Installing from local source: %ARG_SOURCE_DIR% +echo [freeos] Installing from local source: %ARG_SOURCE_DIR% call :prepare_console "%ARG_SOURCE_DIR%" uv pip install "%ARG_SOURCE_DIR%%EXTRAS_SUFFIX%" --python "%VENV_PYTHON%" -if errorlevel 1 (echo [octop] ERROR: install failed & exit /b 1) +if errorlevel 1 (echo [freeos] ERROR: install failed & exit /b 1) goto :install_verify :install_from_git where git >nul 2>&1 -if errorlevel 1 (echo [octop] ERROR: git required & exit /b 1) +if errorlevel 1 (echo [freeos] ERROR: git required & exit /b 1) set "CLONE_DIR=%TEMP%\octop-install-%RANDOM%" if defined ARG_VERSION ( git clone --depth 1 --branch "v%ARG_VERSION%" %OCTOP_REPO% "%CLONE_DIR%" @@ -116,15 +116,15 @@ REM PEP 508: extras must sit between name and version specifier REM (octop[browser]==x.y.z); octop==x.y.z[browser] is an invalid requirement. set "_PACKAGE=octop%EXTRAS_SUFFIX%" if defined ARG_VERSION set "_PACKAGE=octop%EXTRAS_SUFFIX%==%ARG_VERSION%" -echo [octop] Installing %_PACKAGE% from PyPI... +echo [freeos] Installing %_PACKAGE% from PyPI... uv pip install "%_PACKAGE%" --python "%VENV_PYTHON%" -if errorlevel 1 (echo [octop] ERROR: install failed & exit /b 1) +if errorlevel 1 (echo [freeos] ERROR: install failed & exit /b 1) :install_verify call :verify_install if errorlevel 1 exit /b 1 -if not exist "%VENV_OCTOP%" (echo [octop] ERROR: octop CLI not found & exit /b 1) -echo [octop] Octop installed successfully +if not exist "%VENV_OCTOP%" (echo [freeos] ERROR: octop CLI not found & exit /b 1) +echo [freeos] FreeOS installed successfully if "%CONSOLE_AVAILABLE%"=="0" ( "%VENV_PYTHON%" -c "import importlib.resources, octop; p=importlib.resources.files('octop')/'dashboard'/'index.html'; print('yes' if p.is_file() else 'no')" > "%TEMP%\_octop_ui.tmp" 2>&1 @@ -135,22 +135,22 @@ if "%CONSOLE_AVAILABLE%"=="0" ( echo ",%ARG_EXTRAS%," | findstr /i /c:",browser," >nul if errorlevel 1 ( - echo [octop] Skipping Playwright Chromium download. - echo [octop] For remote-browser automation, re-run with -Extras browser, use the dashboard, or: - echo [octop] "%VENV_PYTHON%" -m playwright install chromium + echo [freeos] Skipping Playwright Chromium download. + echo [freeos] For remote-browser automation, re-run with -Extras browser, use the dashboard, or: + echo [freeos] "%VENV_PYTHON%" -m playwright install chromium goto :after_browser ) call :detect_chrome if defined OCTOP_SYSTEM_CHROME ( - echo [octop] Found system Chrome/Chromium: %OCTOP_SYSTEM_CHROME% - echo [octop] Using system browser; skipping Playwright Chromium download. - echo [octop] To use bundled Chromium instead, run: "%VENV_PYTHON%" -m playwright install chromium + echo [freeos] Found system Chrome/Chromium: %OCTOP_SYSTEM_CHROME% + echo [freeos] Using system browser; skipping Playwright Chromium download. + echo [freeos] To use bundled Chromium instead, run: "%VENV_PYTHON%" -m playwright install chromium goto :after_browser ) -echo [octop] Installing Playwright Chromium browser... +echo [freeos] Installing Playwright Chromium browser... "%VENV_PYTHON%" -m playwright install chromium if errorlevel 1 ( - echo [octop] WARNING: Playwright install failed. Run later: "%VENV_PYTHON%" -m playwright install chromium + echo [freeos] WARNING: Playwright install failed. Run later: "%VENV_PYTHON%" -m playwright install chromium ) :after_browser @@ -164,12 +164,12 @@ echo if not defined OCTOP_HOME set "OCTOP_HOME=%%USERPROFILE%%\.freeos" >> "%OCT echo "%%OCTOP_HOME%%\venv\Scripts\octop.exe" %%* >> "%OCTOP_BIN%\octop.cmd" set "OCTOP_BIN_FOR_PS=%OCTOP_BIN%" -powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin=$env:OCTOP_BIN_FOR_PS; $cur=[Environment]::GetEnvironmentVariable('Path','User'); if(-not $cur){$cur=''}; $parts=$cur.Split(';')|?{$_}; $ok=$false; foreach($p in $parts){if($p.TrimEnd('\') -ieq $bin.TrimEnd('\')){$ok=$true}}; if(-not $ok){$new=if($cur){$cur.TrimEnd(';')+';'+$bin}else{$bin}; [Environment]::SetEnvironmentVariable('Path',$new,'User'); Write-Host '[octop] Added to User PATH'} else {Write-Host '[octop] Already in User PATH'}" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$bin=$env:OCTOP_BIN_FOR_PS; $cur=[Environment]::GetEnvironmentVariable('Path','User'); if(-not $cur){$cur=''}; $parts=$cur.Split(';')|?{$_}; $ok=$false; foreach($p in $parts){if($p.TrimEnd('\') -ieq $bin.TrimEnd('\')){$ok=$true}}; if(-not $ok){$new=if($cur){$cur.TrimEnd(';')+';'+$bin}else{$bin}; [Environment]::SetEnvironmentVariable('Path',$new,'User'); Write-Host '[freeos] Added to User PATH'} else {Write-Host '[freeos] Already in User PATH'}" set "OCTOP_BIN_FOR_PS=" echo %PATH% | findstr /i /c:"%OCTOP_BIN%" >nul || set "PATH=%OCTOP_BIN%;%PATH%" echo. -echo Octop installed successfully! +echo FreeOS installed successfully! echo Location: %OCTOP_HOME% echo Python: %PY_VERSION% echo. @@ -217,7 +217,7 @@ exit /b 0 :ensure_uv if defined ARG_UV_PATH ( - if not exist "%ARG_UV_PATH%" (echo [octop] ERROR: uv not found & exit /b 1) + if not exist "%ARG_UV_PATH%" (echo [freeos] ERROR: uv not found & exit /b 1) for %%I in ("%ARG_UV_PATH%") do set "PATH=%%~dpI;!PATH!" goto :ensure_uv_done ) @@ -226,10 +226,10 @@ if not errorlevel 1 goto :ensure_uv_done for %%c in ("%USERPROFILE%\.local\bin\uv.exe" "%LOCALAPPDATA%\uv\uv.exe") do ( if exist %%c (set "PATH=%%~dpc;!PATH!" & goto :ensure_uv_done) ) -echo [octop] Installing uv... +echo [freeos] Installing uv... powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://astral.sh/uv/install.ps1 -TimeoutSec 15 | iex" 2>nul if not errorlevel 1 goto :ensure_uv_refresh -echo [octop] astral.sh failed, trying GitHub... +echo [freeos] astral.sh failed, trying GitHub... call :download_uv_github if errorlevel 1 exit /b 1 goto :ensure_uv_done @@ -238,7 +238,7 @@ goto :ensure_uv_done for %%p in ("%USERPROFILE%\.local\bin" "%LOCALAPPDATA%\uv") do ( if exist %%p echo "!PATH!" | findstr /i /c:"%%~p" >nul || set "PATH=%%~p;!PATH!" ) -where uv >nul 2>&1 || (echo [octop] ERROR: uv install failed & exit /b 1) +where uv >nul 2>&1 || (echo [freeos] ERROR: uv install failed & exit /b 1) :ensure_uv_done exit /b 0 @@ -260,7 +260,7 @@ set "_DEST=%_RD%\src\octop\dashboard" if exist "%_DEST%\index.html" (set "CONSOLE_AVAILABLE=1" & exit /b 0) if not exist "%_RD%\dashboard\package.json" exit /b 0 where npm >nul 2>&1 || exit /b 0 -echo [octop] Building dashboard... +echo [freeos] Building dashboard... pushd "%_RD%\dashboard" npm ci && npm run build popd @@ -268,8 +268,8 @@ if exist "%_DEST%\index.html" set "CONSOLE_AVAILABLE=1" exit /b 0 :verify_install -echo [octop] Verifying installation... +echo [freeos] Verifying installation... "%VENV_PYTHON%" -c "from octop.infra.agents.manager import AgentManager" >nul 2>&1 -if errorlevel 1 (echo [octop] ERROR: install verification failed & exit /b 1) -echo [octop] Install verification passed +if errorlevel 1 (echo [freeos] ERROR: install verification failed & exit /b 1) +echo [freeos] Install verification passed exit /b 0 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 506f4875..095bd166 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,4 +1,4 @@ -# Octop Installer for Windows (PowerShell) +# FreeOS Installer for Windows (PowerShell) # Usage: irm /install.ps1 | iex # or: .\install.ps1 [-Version X.Y.Z] [-FromSource] [-SourceDir DIR] [-Extras "browser"] # @@ -37,16 +37,16 @@ $OctopBin = Join-Path $OctopHome "bin" $PythonVersion = "3.12" $OctopRepo = if ($env:OCTOP_REPO) { $env:OCTOP_REPO } else { "https://github.com/XYAIStudio/FreeOS.git" } -function Write-Info { param([string]$Message) Write-Host "[octop] " -ForegroundColor Green -NoNewline; Write-Host $Message } -function Write-Warn { param([string]$Message) Write-Host "[octop] " -ForegroundColor Yellow -NoNewline; Write-Host $Message } -function Stop-WithError { param([string]$Message) Write-Host "[octop] ERROR: $Message" -ForegroundColor Red; exit 1 } +function Write-Info { param([string]$Message) Write-Host "[freeos] " -ForegroundColor Green -NoNewline; Write-Host $Message } +function Write-Warn { param([string]$Message) Write-Host "[freeos] " -ForegroundColor Yellow -NoNewline; Write-Host $Message } +function Stop-WithError { param([string]$Message) Write-Host "[freeos] ERROR: $Message" -ForegroundColor Red; exit 1 } if ($FromSource -and $FromPyPI) { Stop-WithError "Choose only one of -FromSource or -FromPyPI." } if (-not $FromPyPI) { $FromSource = $true } if ($Help) { @" -Octop Installer for Windows +FreeOS Installer for Windows Usage: .\install.ps1 [OPTIONS] @@ -73,7 +73,7 @@ Environment: exit 0 } -Write-Host "[octop] Installing Octop into $OctopHome" -ForegroundColor Green +Write-Host "[freeos] Installing FreeOS into $OctopHome" -ForegroundColor Green $policy = Get-ExecutionPolicy if ($policy -eq "Restricted") { @@ -273,7 +273,7 @@ Pin-McpCompat Test-Install if (-not (Test-Path $VenvOctop)) { Stop-WithError "Installation failed: octop CLI not found in venv" } -Write-Info "Octop installed successfully" +Write-Info "FreeOS installed successfully" if (-not $script:ConsoleAvailable) { $check = & $VenvPython -c "import importlib.resources, octop; p=importlib.resources.files('octop')/'dashboard'/'index.html'; print('yes' if p.is_file() else 'no')" 2>&1 @@ -350,7 +350,7 @@ function Resolve-FreeOSHome { $OctopHome = Resolve-FreeOSHome $RealBin = Join-Path $OctopHome "venv\Scripts\octop.exe" if (-not (Test-Path $RealBin)) { - Write-Error "Octop environment not found at $OctopHome\venv" + Write-Error "FreeOS environment not found at $OctopHome\venv" exit 1 } & $RealBin @args @@ -372,7 +372,7 @@ if defined FREEOS_HOME ( ) set "REAL_BIN=%OCTOP_HOME%\venv\Scripts\octop.exe" if not exist "%REAL_BIN%" ( - echo Error: Octop environment not found at %OCTOP_HOME%\venv >&2 + echo Error: FreeOS environment not found at %OCTOP_HOME%\venv >&2 exit /b 1 ) "%REAL_BIN%" %* @@ -403,7 +403,7 @@ if ($env:Path -notlike "*$targetPath*") { } Write-Host "" -Write-Host "Octop installed successfully!" -ForegroundColor Green +Write-Host "FreeOS installed successfully!" -ForegroundColor Green Write-Host "" Write-Host " Install location: $OctopHome" Write-Host " Python: $pyVersion" diff --git a/scripts/install.sh b/scripts/install.sh index 246a398b..33c2dd94 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Octop 安装脚本 (macOS / Linux) +# FreeOS 安装脚本 (macOS / Linux) # 用法: bash scripts/install.sh # 从 FreeOS GitHub 源码安装(默认) # 或: bash scripts/install.sh --from-pypi # 从 PyPI 安装 Octop(不含 FreeOS 下游改动) # 或: curl -fsSL /install.sh | bash # 远程安装 @@ -77,9 +77,9 @@ else BOLD="" GREEN="" YELLOW="" RED="" RESET="" fi -info() { printf "${GREEN}[octop]${RESET} %s\n" "$*"; } -warn() { printf "${YELLOW}[octop]${RESET} %s\n" "$*"; } -error() { printf "${RED}[octop]${RESET} %s\n" "$*" >&2; } +info() { printf "${GREEN}[freeos]${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}[freeos]${RESET} %s\n" "$*"; } +error() { printf "${RED}[freeos]${RESET} %s\n" "$*" >&2; } die() { error "$@"; exit 1; } # ── 解析参数 ────────────────────────────────────────────────────────────────── @@ -104,7 +104,7 @@ while [[ $# -gt 0 ]]; do PYPI_MIRROR="$2"; shift 2 ;; -h|--help) cat </dev/null || die "git is required to clone the repos. Install git or use --from-pypi." mkdir -p "$workdir" - info "Cloning harness-agent / harness-gateway / Octop sources..." + info "Cloning harness-agent / harness-gateway / FreeOS sources..." git clone --depth 1 "$HARNESS_AGENT_REPO" "$workdir/harness-agent" git clone --depth 1 "$HARNESS_GATEWAY_REPO" "$workdir/harness-gateway" git clone --depth 1 "$HARNESS_BROWSER_REPO" "$workdir/harness-browser" @@ -724,7 +724,7 @@ _pin_mcp_compat || true _verify_install [ -x "$OCTOP_VENV/bin/octop" ] || die "Install failed: octop CLI not found in the virtualenv" -info "Octop installed successfully" +info "FreeOS installed successfully" if [ "$_CONSOLE_AVAILABLE" = 0 ]; then CONSOLE_CHECK="$("$OCTOP_VENV/bin/python" -c "import importlib.resources, octop; p=importlib.resources.files('octop')/'dashboard'/'index.html'; print('yes' if p.is_file() else 'no')" 2>/dev/null || echo 'no')" @@ -1014,7 +1014,7 @@ OCTOP_HOME="$(resolve_install_home)" REAL_BIN="$OCTOP_HOME/venv/bin/octop" if [ ! -x "$REAL_BIN" ]; then - echo "Error: Octop environment not found in $OCTOP_HOME/venv" >&2 + echo "Error: FreeOS environment not found in $OCTOP_HOME/venv" >&2 echo "Please re-run the installer" >&2 exit 1 fi @@ -1115,7 +1115,7 @@ add_to_profile() { return 0 fi if [ -f "$profile" ] || [ "$create" = "create" ]; then - printf '\n# Octop\n%s\n' "$PATH_ENTRY" >> "$profile" + printf '\n# FreeOS\n%s\n' "$PATH_ENTRY" >> "$profile" info "Updated $profile" return 0 fi @@ -1125,7 +1125,7 @@ add_to_profile() { _write_profile_d() { # CentOS / Ubuntu 登录 shell 会加载 /etc/profile.d/*.sh local dest="/etc/profile.d/octop.sh" - local content="# Octop CLI + local content="# FreeOS CLI export PATH=\"${OCTOP_BIN}:\$PATH\" " if [ -d /etc/profile.d ]; then @@ -1176,7 +1176,7 @@ fi # ── 完成 ────────────────────────────────────────────────────────────────────── echo "" -printf "${GREEN}${BOLD}Octop installed successfully!${RESET}\n" +printf "${GREEN}${BOLD}FreeOS installed successfully!${RESET}\n" echo "" printf " Location: ${BOLD}%s${RESET}\n" "$OCTOP_HOME" printf " Python: ${BOLD}%s${RESET}\n" "$("$OCTOP_VENV/bin/python" --version 2>&1)" diff --git a/src/octop/infra/setup/service.py b/src/octop/infra/setup/service.py index f84fc77a..aa0e61b2 100644 --- a/src/octop/infra/setup/service.py +++ b/src/octop/infra/setup/service.py @@ -401,7 +401,7 @@ def render_systemd_unit(runtime: ServiceRuntime, *, user_unit: bool | None = Non nofile_line = f"LimitNOFILE={nofile}\n" if nofile is not None else "" return ( "[Unit]\n" - "Description=Octop AI Server\n" + "Description=FreeOS AI Server\n" "After=network.target\n" "\n" "[Service]\n" From 0e5c67fabe017f230afe0a730757dad6d434826c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 07:34:56 +0000 Subject: [PATCH 16/39] fix(home): keep FreeOS writes inside FREEOS_HOME Default agent root_dir followed Path.home(), so setup/finish seeded main at ~/.octop/workspaces and overwrote an existing profile when --home pointed elsewhere. Workspaces, the wizard password file, and llama.cpp logs now stay under the product home. A pre-existing directory outside that home is refused, and default-agent seed will not replace files that are already there. Co-authored-by: haoboy007-sudo --- dashboard/src/locales/en.json | 4 +- dashboard/src/locales/zh.json | 4 +- .../src/build/windows/nsis/start-sidecar.ps1 | 1 + docker/docker-compose.yml | 9 +- docker/docker-entrypoint.sh | 26 ++- scripts/run-org-sidecar.sh | 13 +- src/octop/api/routers/filesystem.py | 7 +- src/octop/api/routers/setup.py | 9 +- src/octop/i18n/en.json | 4 +- src/octop/i18n/zh.json | 4 +- src/octop/infra/agents/default_agent.py | 27 ++- src/octop/infra/agents/execute_env.py | 1 + .../agents/providers/llamacpp_runtime.py | 3 +- src/octop/infra/agents/workspace_dir.py | 111 ++++++++++- src/octop/infra/errors.py | 4 + src/octop/infra/server.py | 8 +- src/octop/infra/setup/password_file.py | 16 +- tests/integration/test_filesystem_api.py | 14 +- tests/integration/test_home_isolation.py | 179 ++++++++++++++++++ tests/integration/test_invites_api.py | 4 +- .../test_postgresql_control_plane.py | 2 +- tests/integration/test_setup_bootstrap.py | 11 +- tests/integration/test_setup_database.py | 2 +- tests/integration/test_setup_wizard.py | 48 ++--- tests/support/auth.py | 2 +- tests/unit/agents/test_default_agent.py | 8 +- tests/unit/agents/test_home_write_guard.py | 63 ++++++ tests/unit/test_wizard_password.py | 17 ++ 28 files changed, 519 insertions(+), 82 deletions(-) create mode 100644 tests/integration/test_home_isolation.py create mode 100644 tests/unit/agents/test_home_write_guard.py diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index a80b207a..a88c128c 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -441,7 +441,9 @@ "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({{used}}/{{quota}}).", "LOCAL_MODEL_REGISTER_FAILED": "Could not register the local model: {{reason}}", - "CONFIG_FILE_CORRUPT": "Cannot update {{path}}: the file is not valid JSON ({{detail}}). Fix the syntax and retry — no settings were changed." + "CONFIG_FILE_CORRUPT": "Cannot update {{path}}: the file is not valid JSON ({{detail}}). Fix the syntax and retry — no settings were changed.", + "HOME_WRITE_REFUSED": "Refusing to write into {{path}}. That directory already exists and is not inside the current home ({{home}}).", + "AGENT_SEED_EXISTS": "Refusing to seed the default agent over existing data at {{path}}." }, "nav": { "chat": "Conversation", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index edb2a764..dcb165c7 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -441,7 +441,9 @@ "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({{used}}/{{quota}})。", "LOCAL_MODEL_REGISTER_FAILED": "无法注册本地模型:{{reason}}", - "CONFIG_FILE_CORRUPT": "无法更新 {{path}}:该文件不是合法 JSON({{detail}})。请修正语法后重试;现有配置未被改动。" + "CONFIG_FILE_CORRUPT": "无法更新 {{path}}:该文件不是合法 JSON({{detail}})。请修正语法后重试;现有配置未被改动。", + "HOME_WRITE_REFUSED": "拒绝写入 {{path}}。该目录已存在,且不在当前家目录({{home}})之内。", + "AGENT_SEED_EXISTS": "拒绝把默认智能体播种到已有数据上:{{path}}。" }, "nav": { "chat": "对话", diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 84aed5d9..b5a2b50e 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -209,6 +209,7 @@ if (-not (Test-Path -LiteralPath $node)) { exit 6 } # $ErrorActionPreference = Continue the script keeps going and $home stays # the user profile root, so SQLite lands at %USERPROFILE%\org-os\... $freeosHome = $env:FREEOS_HOME +if (-not $freeosHome) { $freeosHome = $env:OCTOP_HOME } if (-not $freeosHome) { $freeosHome = Join-Path $env:USERPROFILE '.freeos' } $data = Join-Path $freeosHome 'org-os' New-Item -ItemType Directory -Force -Path $data | Out-Null diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a5001987..26236f8a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,8 @@ # FreeOS / Octop — Docker Compose (single Python process) # # One service. No Node / openXYOS sidecar. Organization is in-host. -# Product home name is FREEOS_HOME; OCTOP_* and ~/.octop remain aliases. +# Product home is FREEOS_HOME (/data/.freeos). OCTOP_HOME is the same path. +# Host default mount is ~/.freeos. Set OCTOP_DATA to point at an existing tree. # Published image: ghcr.io/xyaistudio/freeos # Local build tag remains octop:latest (compatibility). # @@ -19,7 +20,7 @@ # OCTOP_DATABASE_URL=postgresql://octop:octop@host.docker.internal:5432/octop # # 注意: docker/.env 仅用于 Compose 插值;变量必须列在下方 environment -# 才会进入容器。也可写入数据卷内的 ~/.octop/env(宿主机挂载目录下的 env)。 +# 才会进入容器。也可写入数据卷内的 env(宿主机挂载目录下的 env)。 # 密码须 ≥8 位且同时包含字母与数字(与控制台改密策略一致)。 # 后续计划:首次启动可改为随机生成密码并写入 credential.txt。 # ============================================================================= @@ -35,9 +36,11 @@ services: ports: - "${OCTOP_PORT:-8088}:${OCTOP_PORT:-8088}" volumes: - - ${OCTOP_DATA:-~/.octop}:/data/.octop + - ${OCTOP_DATA:-~/.freeos}:/data/.freeos environment: - HOME=/data + - FREEOS_HOME=/data/.freeos + - OCTOP_HOME=/data/.freeos - OCTOP_BIND_HOST=0.0.0.0 - OCTOP_PORT=${OCTOP_PORT:-8088} - OCTOP_LOG_LEVEL=${OCTOP_LOG_LEVEL:-info} diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index a5de348c..6b10405d 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -4,8 +4,8 @@ # # 环境变量: # HOME — 必须为 /data,使 ~/.octop 映射到数据卷 -# FREEOS_HOME — 产品家目录(可选;应用优先于 OCTOP_HOME) -# OCTOP_HOME — 遗留别名。入口脚本仍用 ~/.octop 做首次 init +# FREEOS_HOME — 产品家目录(优先于 OCTOP_HOME) +# OCTOP_HOME — 遗留别名。未设置 FREEOS_HOME 时使用 # OCTOP_DEFAULT_PASSWORD — 首次管理员密码(须 ≥8 位且含字母和数字; # 不设置则自动生成随机密码,凭据写入 # /data/.octop/credential.txt) @@ -21,9 +21,25 @@ set -euo pipefail export HOME="${HOME:-/data}" -OCTOP_HOME="${HOME}/.octop" -DB_FILE="${OCTOP_HOME}/octop.db" -CREDENTIAL_FILE="${OCTOP_HOME}/credential.txt" +# Honor an explicit product home. Otherwise follow PathLayout: an existing +# ~/.freeos, an existing ~/.octop, else a new ~/.freeos. Do not ignore +# FREEOS_HOME in favor of ${HOME}/.octop. +if [ -n "${FREEOS_HOME:-}" ]; then + PRODUCT_HOME="$FREEOS_HOME" +elif [ -n "${OCTOP_HOME:-}" ]; then + PRODUCT_HOME="$OCTOP_HOME" +elif [ -d "${HOME}/.freeos" ]; then + PRODUCT_HOME="${HOME}/.freeos" +elif [ -d "${HOME}/.octop" ]; then + PRODUCT_HOME="${HOME}/.octop" +else + PRODUCT_HOME="${HOME}/.freeos" +fi +export FREEOS_HOME="$PRODUCT_HOME" +export OCTOP_HOME="$PRODUCT_HOME" +mkdir -p "$PRODUCT_HOME" +DB_FILE="${PRODUCT_HOME}/octop.db" +CREDENTIAL_FILE="${PRODUCT_HOME}/credential.txt" ADMIN_USERNAME="${OCTOP_ADMIN_USERNAME:-admin}" ADMIN_DISPLAY_NAME="${OCTOP_ADMIN_DISPLAY_NAME:-Admin}" PORT="${OCTOP_PORT:-8088}" diff --git a/scripts/run-org-sidecar.sh b/scripts/run-org-sidecar.sh index ba07ad2e..41968057 100755 --- a/scripts/run-org-sidecar.sh +++ b/scripts/run-org-sidecar.sh @@ -15,9 +15,10 @@ fi mkdir -p "${DATA_DIR}" cd "${APP}" -if [[ ! -f .env ]]; then +# Secrets stay under the product home. Do not write .env into the source tree. +if [[ ! -f "${DATA_DIR}/sidecar.env" ]]; then umask 077 - cat > .env < "${DATA_DIR}/sidecar.env" < dict[str, Any]: - """Return the process home path and browse-tree root (host ``/`` on POSIX).""" + """Return the OS profile path and the product-home default ``root_dir``.""" home = host_path_text(host_home_dir()) + product_home = host_path_text(server.paths.root) allowed = _user_workspace_root(server, user) if allowed: return { @@ -85,7 +86,7 @@ async def filesystem_defaults( } return { "home": home, - "default_root_dir": home, + "default_root_dir": product_home, "allow_outside_home": True, "tree_root": host_fs_tree_root(allow_outside_home=True), } diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index ca63b209..4cf275ce 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -4,7 +4,6 @@ import json import logging -from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, Header, Request @@ -176,6 +175,7 @@ async def _bootstrap_default_agent(server: Any, *, user_id: int, locale: str = " user_id=user_id, locale=locale, agent_id=SETUP_DEFAULT_AGENT_ID, + product_home=server.paths.root, ) @@ -250,7 +250,8 @@ async def status(server: Any = Depends(get_server)) -> dict[str, Any]: """Whether initial admin creation is still required and wizard password file state.""" from octop.infra.agents.permission_mode import supports_request_interrupt_override - wizard_path = str(Path.home() / _wizard.WIZARD_FILE_NAME) + wizard_home = server.paths.root + wizard_path = str(wizard_home / _wizard.WIZARD_FILE_NAME) password_required = _setup_password_required(server) um = server.user_manager setup_required = um is None or um.count() == 0 @@ -263,7 +264,7 @@ async def status(server: Any = Depends(get_server)) -> dict[str, Any]: "setup_required": setup_required, "wizard_password_required": password_required, "wizard_password_exists": ( - password_required and _wizard.read_password(Path.home()) is not None + password_required and _wizard.read_password(wizard_home) is not None ), "wizard_password_path": wizard_path if password_required else None, "database_driver": database_driver, @@ -354,7 +355,7 @@ async def verify_password( server.wizard_tokens.record_attempt(client_ip) except RateLimited: raise OctopError(ErrorCode.SETUP_RATE_LIMITED, "too many attempts") from None - expected = _wizard.read_password(Path.home()) + expected = _wizard.read_password(server.paths.root) if expected is None or body.password != expected: raise OctopError(ErrorCode.SETUP_PASSWORD_WRONG, "wrong password") token, ttl = server.wizard_tokens.issue() diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index a51f35e9..c45f4f5c 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -406,7 +406,9 @@ "WORKSPACE_ROOT_RESTRICTED": "The selected storage root is outside the allowed directory for this account.", "TOKEN_QUOTA_EXCEEDED": "This account has reached its token quota. Please contact an administrator to raise the limit ({used}/{quota}).", "LOCAL_MODEL_REGISTER_FAILED": "Could not register the local model: {reason}", - "CONFIG_FILE_CORRUPT": "Cannot update {path}: the file is not valid JSON ({detail}). Fix the syntax and retry — no settings were changed." + "CONFIG_FILE_CORRUPT": "Cannot update {path}: the file is not valid JSON ({detail}). Fix the syntax and retry — no settings were changed.", + "HOME_WRITE_REFUSED": "Refusing to write into {path}. That directory already exists and is not inside the current home ({home}).", + "AGENT_SEED_EXISTS": "Refusing to seed the default agent over existing data at {path}." }, "desktop": { "install_log_deps": "Installing Python dependencies…", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 0f9f7c5d..57385a9a 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -406,7 +406,9 @@ "WORKSPACE_ROOT_RESTRICTED": "所选存储根目录超出了该账号允许的范围。", "TOKEN_QUOTA_EXCEEDED": "该账号已达到 token 配额上限,请联系管理员提升额度({used}/{quota})。", "LOCAL_MODEL_REGISTER_FAILED": "无法注册本地模型:{reason}", - "CONFIG_FILE_CORRUPT": "无法更新 {path}:该文件不是合法 JSON({detail})。请修正语法后重试;现有配置未被改动。" + "CONFIG_FILE_CORRUPT": "无法更新 {path}:该文件不是合法 JSON({detail})。请修正语法后重试;现有配置未被改动。", + "HOME_WRITE_REFUSED": "拒绝写入 {path}。该目录已存在,且不在当前家目录({home})之内。", + "AGENT_SEED_EXISTS": "拒绝把默认智能体播种到已有数据上:{path}。" }, "desktop": { "install_log_deps": "正在安装 Python 依赖…", diff --git a/src/octop/infra/agents/default_agent.py b/src/octop/infra/agents/default_agent.py index c2810110..11ec053a 100644 --- a/src/octop/infra/agents/default_agent.py +++ b/src/octop/infra/agents/default_agent.py @@ -7,12 +7,14 @@ from __future__ import annotations import logging +from pathlib import Path from typing import Any from octop.infra.agents.experts.catalog import ExpertCatalog, build_create_spec_from_expert from octop.infra.errors import ErrorCode, OctopError -from octop.infra.utils.host_dirs import host_home_dir, host_path_text +from octop.infra.utils.host_dirs import host_path_text from octop.infra.utils.locale import normalize_locale +from octop.infra.utils.paths import PathLayout logger = logging.getLogger(__name__) @@ -20,11 +22,17 @@ SETUP_DEFAULT_AGENT_ID = "main" -def default_home_local_backend() -> dict[str, Any]: - """Same local backend as the dashboard create-from-expert default (home-scoped).""" +def default_home_local_backend(product_home: Path | None = None) -> dict[str, Any]: + """Local backend rooted at the FreeOS home (``FREEOS_HOME`` / ``--home``). + + The OS profile (``Path.home()``) is not a data root. Scoped workspaces are + ``{root_dir}/.octop/workspaces/``, so a profile root writes into + ``~/.octop`` even when the process was started with a separate home. + """ + root = product_home if product_home is not None else PathLayout.from_env().root return { "type": "local_shell", - "root_dir": host_path_text(host_home_dir()), + "root_dir": host_path_text(root), "virtual_mode": True, } @@ -36,6 +44,7 @@ async def bootstrap_default_agent( user_id: int, locale: str = "zh", agent_id: str | None = None, + product_home: Path | None = None, ) -> Any | None: """Create ``general-assistant`` for *user_id* when they have no agents yet. @@ -61,7 +70,7 @@ async def bootstrap_default_agent( user_id=user_id, agent_id=agent_id, locale=loc, - config_extra={"backend": default_home_local_backend()}, + config_extra={"backend": default_home_local_backend(product_home)}, ) return await registry.create(spec, defer_bootstrap=True) @@ -72,11 +81,18 @@ async def try_bootstrap_default_agent( user_id: int, locale: str = "zh", agent_id: str | None = None, + product_home: Path | None = None, ) -> None: """Best-effort wrapper used by HTTP adapters (setup / invite).""" if server.app_runtime is None: logger.warning("skip default agent bootstrap: app_runtime not ready") return + home = product_home + if home is None: + paths = getattr(server, "paths", None) + root = getattr(paths, "root", None) + if isinstance(root, Path): + home = root try: await bootstrap_default_agent( server.app_runtime.agent_registry, @@ -84,6 +100,7 @@ async def try_bootstrap_default_agent( user_id=user_id, locale=locale, agent_id=agent_id, + product_home=home, ) except Exception as exc: # pragma: no cover - logged; user creation should still succeed logger.warning("could not auto-create default agent for user %s: %s", user_id, exc) diff --git a/src/octop/infra/agents/execute_env.py b/src/octop/infra/agents/execute_env.py index 4b9d9a15..49c05ee4 100644 --- a/src/octop/infra/agents/execute_env.py +++ b/src/octop/infra/agents/execute_env.py @@ -85,6 +85,7 @@ def agent_execute_env_defaults( return { "OCTOP_AGENT_ID": agent_id, "OCTOP_AUTH_DIR": auth_env, + "FREEOS_HOME": str(paths.root), "OCTOP_HOME": str(paths.root), "OCTOP_SKILLS_DIR": skills_env, } diff --git a/src/octop/infra/agents/providers/llamacpp_runtime.py b/src/octop/infra/agents/providers/llamacpp_runtime.py index 55de5d1e..6d2b864f 100644 --- a/src/octop/infra/agents/providers/llamacpp_runtime.py +++ b/src/octop/infra/agents/providers/llamacpp_runtime.py @@ -108,8 +108,7 @@ def _validated_model_path(raw: str) -> Path: def _log_path() -> Path: - configured = os.environ.get("OCTOP_HOME", "").strip() - root = Path(configured).expanduser() if configured else Path.home() / ".octop" + root = PathLayout.from_env().root path = root / "logs" / "llama-sidecar.log" path.parent.mkdir(parents=True, exist_ok=True) return path diff --git a/src/octop/infra/agents/workspace_dir.py b/src/octop/infra/agents/workspace_dir.py index e0dd456e..3624b1b3 100644 --- a/src/octop/infra/agents/workspace_dir.py +++ b/src/octop/infra/agents/workspace_dir.py @@ -30,6 +30,7 @@ from pathlib import Path from typing import Any +from octop.infra.errors import ErrorCode, OctopError from octop.infra.utils.paths import PathLayout DEFAULT_SYSTEM_FILES_PATH = ".octop" @@ -42,6 +43,98 @@ def _running_on_windows() -> bool: return os.name == "nt" +def path_is_within(child: str, root: str) -> bool: + """True when *child* is *root* or a descendant. + + Drive-letter paths are compared case-insensitively so Windows + ``C:\\Users\\...`` and ``c:/Users/...`` match. POSIX paths stay + case-sensitive. Used to keep FreeOS writes inside the product home. + """ + + def norm(raw: str) -> str: + text = raw.strip().replace("\\", "/") + if len(text) > 1: + text = text.rstrip("/") + windows = os.name == "nt" or bool(re.match(r"^[A-Za-z]:", text)) + if windows and len(text) >= 2 and text[1] == ":": + text = text[0].lower() + text[1:] + return text.casefold() + if os.name == "nt": + return text.casefold() + return text + + child_n = norm(child) + root_n = norm(root) + if not child_n or not root_n: + return False + return child_n == root_n or child_n.startswith(root_n + "/") + + +def _resolved_text(path: Path) -> str: + try: + return str(path.resolve()) + except OSError: + return str(path.absolute()) + + +def path_is_under_home(path: Path, home: Path) -> bool: + """True when *path* is the product home or a directory inside it.""" + return path_is_within(_resolved_text(path), _resolved_text(home)) + + +def workspace_has_user_payload(path: Path) -> bool: + """True when *path* already holds agent files FreeOS must not replace.""" + if not path.exists(): + return False + if not path.is_dir(): + return True + names = ( + "SOUL.md", + "manifest.json", + "memory.sqlite", + "memory.sqlite-wal", + "memory.sqlite-shm", + ) + if any((path / name).exists() for name in names): + return True + nested = path / DEFAULT_SYSTEM_FILES_PATH + if nested.is_dir() and any( + (nested / name).exists() for name in ("manifest.json", "memory.sqlite", "skills") + ): + return True + skills = path / "skills" + if skills.is_dir(): + for skill_md in skills.rglob("SKILL.md"): + if skill_md.is_file(): + return True + return False + + +def refuse_existing_outside_home(path: Path, home: Path) -> None: + """Refuse to adopt a directory that already exists outside the product home.""" + if path_is_under_home(path, home) or not path.exists(): + return + raise OctopError( + ErrorCode.HOME_WRITE_REFUSED, + ( + f"refusing to write into {path}: it already exists and is not inside " + f"the current home {home}" + ), + details={"path": str(path), "home": str(home)}, + ) + + +def refuse_seed_over_existing(path: Path) -> None: + """Refuse to seed a default agent workspace that already has user files.""" + if not workspace_has_user_payload(path): + return + raise OctopError( + ErrorCode.AGENT_SEED_EXISTS, + f"refusing to seed the default agent over existing data at {path}", + details={"path": str(path)}, + ) + + def _is_host_root_sentinel(root: str | Path | None) -> bool: if root is None: return True @@ -102,9 +195,12 @@ def default_agent_workspace_dir( return paths.agent_workspace(agent_id) out = root / DEFAULT_SYSTEM_FILES_PATH / SCOPED_WORKSPACE_DIRNAME / agent_id if ensure: + refuse_existing_outside_home(out, paths.root) out.mkdir(parents=True, exist_ok=True) return out if ensure: + target = paths.agent_workspace(agent_id) + refuse_existing_outside_home(target, paths.root) return paths.ensure_agent_workspace(agent_id) return paths.agent_workspace(agent_id) @@ -117,14 +213,19 @@ def seed_workspace_dir_on_create( ) -> Path: """Ensure ``config["workspace_dir"]`` at create; return on-disk Path for seeding.""" raw = config.get("workspace_dir") - if isinstance(raw, str) and raw.strip(): - text = raw.strip() + text = raw.strip() if isinstance(raw, str) else "" + if text: host = resolve_workspace_host_path(text, config) + refuse_existing_outside_home(host, paths.root) host.mkdir(parents=True, exist_ok=True) config["workspace_dir"] = text return host.resolve() - host = default_agent_workspace_dir(paths, agent_id, cfg=config).resolve() + host = default_agent_workspace_dir(paths, agent_id, cfg=config, ensure=False) + refuse_existing_outside_home(host, paths.root) + refuse_seed_over_existing(host) + host.mkdir(parents=True, exist_ok=True) + host = host.resolve() if uses_scoped_workspace_default(config): config["workspace_dir"] = scoped_workspace_dir_str(agent_id) else: @@ -374,6 +475,10 @@ def workspace_dir_from_config_json( "host_system_dir", "join_agent_facing", "local_backend_root_dir", + "path_is_under_home", + "path_is_within", + "refuse_existing_outside_home", + "refuse_seed_over_existing", "resolve_workspace_host_path", "scoped_workspace_dir_str", "seed_workspace_dir_on_create", diff --git a/src/octop/infra/errors.py b/src/octop/infra/errors.py index 5033812c..1851b2f9 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -111,6 +111,8 @@ class ErrorCode(StrEnum): TOKEN_QUOTA_EXCEEDED = "TOKEN_QUOTA_EXCEEDED" LOCAL_MODEL_REGISTER_FAILED = "LOCAL_MODEL_REGISTER_FAILED" CONFIG_FILE_CORRUPT = "CONFIG_FILE_CORRUPT" + HOME_WRITE_REFUSED = "HOME_WRITE_REFUSED" + AGENT_SEED_EXISTS = "AGENT_SEED_EXISTS" _DEFAULT_STATUS: dict[ErrorCode, int] = { @@ -213,6 +215,8 @@ class ErrorCode(StrEnum): ErrorCode.TOKEN_QUOTA_EXCEEDED: 403, ErrorCode.LOCAL_MODEL_REGISTER_FAILED: 400, ErrorCode.CONFIG_FILE_CORRUPT: 400, + ErrorCode.HOME_WRITE_REFUSED: 409, + ErrorCode.AGENT_SEED_EXISTS: 409, } diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index 85844edd..5f201d5b 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -527,7 +527,7 @@ def _emit_wizard_password(self, *, user_count: int) -> None: config = self.config if config is None: return - wizard_home = Path.home() + wizard_home = self.paths.root if config.require_setup_password: try: new_pw = _wizard_pw.boot_self_heal(wizard_home, user_count=user_count) @@ -543,15 +543,11 @@ def _emit_wizard_password(self, *, user_count: int) -> None: "║ FreeOS first-run wizard password (one-time use): ║\n" f"║ {new_pw:<54} ║\n" "║ Open the dashboard and paste it into the setup wizard. ║\n" - "║ File: ~/octop-login.txt ║\n" "╚══════════════════════════════════════════════════════════╝" "\033[0m\n" ) print(banner, flush=True) - logger.info( - "wizard password generated; file=%s", - wizard_home / WIZARD_FILE_NAME, - ) + print(f"Wizard password file: {wizard_home / WIZARD_FILE_NAME}", flush=True) async def stop(self) -> None: if not self._started: diff --git a/src/octop/infra/setup/password_file.py b/src/octop/infra/setup/password_file.py index 4e65d636..e1833ec9 100644 --- a/src/octop/infra/setup/password_file.py +++ b/src/octop/infra/setup/password_file.py @@ -1,11 +1,18 @@ -"""Setup wizard password file at ``/octop-login.txt``.""" +"""Setup wizard password file at ``/octop-login.txt``. + +``home`` is the FreeOS data directory (``FREEOS_HOME`` / ``--home``), not the +OS profile. The password itself is never written to logs. +""" from __future__ import annotations +import logging import os import secrets from pathlib import Path +logger = logging.getLogger(__name__) + WIZARD_FILE_NAME = "octop-login.txt" _TOKEN_BYTES = 16 _PASSWORD_LABEL_PREFIX = "Password:" @@ -77,5 +84,10 @@ def boot_self_heal(home: Path, user_count: int) -> str | None: """ if user_count > 0: return None + target = _password_path(home) created = ensure_password(home) - return created if created is not None else read_password(home) + if created is not None: + logger.info("wizard password file created; path=%s", target) + return created + logger.info("wizard password file reused; path=%s", target) + return read_password(home) diff --git a/tests/integration/test_filesystem_api.py b/tests/integration/test_filesystem_api.py index 3c369e44..566c7e25 100644 --- a/tests/integration/test_filesystem_api.py +++ b/tests/integration/test_filesystem_api.py @@ -96,13 +96,13 @@ async def test_ensure_bwrap_returns_status_shape( @pytest.mark.asyncio async def test_filesystem_defaults_for_admin( - env_admin_client: tuple[httpx.AsyncClient, dict[str, str]], + env: tuple[httpx.AsyncClient, Any, dict[str, str]], monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from octop.infra.utils.host_dirs import host_fs_tree_root + from octop.infra.utils.host_dirs import host_fs_tree_root, host_path_text - client, auth = env_admin_client + client, srv, auth = env home = tmp_path / "os_home" home.mkdir() monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home) @@ -111,7 +111,7 @@ async def test_filesystem_defaults_for_admin( assert r.status_code == 200, r.text body = r.json() assert body["home"] == home.resolve().as_posix() - assert body["default_root_dir"] == home.resolve().as_posix() + assert body["default_root_dir"] == host_path_text(srv.paths.root) assert body["allow_outside_home"] is True assert body["tree_root"] == host_fs_tree_root(allow_outside_home=True) @@ -122,10 +122,10 @@ async def test_non_admin_can_list_outside_home( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - from octop.infra.utils.host_dirs import host_fs_tree_root + from octop.infra.utils.host_dirs import host_fs_tree_root, host_path_text from tests.support.auth import create_user - client, _srv, admin_auth = env + client, srv, admin_auth = env home = tmp_path / "os_home" home.mkdir() outside = tmp_path / "outside" @@ -138,7 +138,7 @@ async def test_non_admin_can_list_outside_home( assert defaults.status_code == 200, defaults.text body = defaults.json() assert body["allow_outside_home"] is True - assert body["default_root_dir"] == home.resolve().as_posix() + assert body["default_root_dir"] == host_path_text(srv.paths.root) assert body["tree_root"] == host_fs_tree_root(allow_outside_home=True) listed = await client.get( diff --git a/tests/integration/test_home_isolation.py b/tests/integration/test_home_isolation.py new file mode 100644 index 00000000..ffc760f0 --- /dev/null +++ b/tests/integration/test_home_isolation.py @@ -0,0 +1,179 @@ +"""First-start with --home must not touch a populated OS profile.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest + +from octop.api.app import build_app +from octop.api.routers.chat.models import ChatTurnBody +from octop.api.routers.chat.turn import prepare_dashboard_turn +from octop.infra.agents.providers.llamacpp_runtime import _log_path +from octop.infra.server import OctopServer +from octop.infra.setup.password_file import WIZARD_FILE_NAME, read_password +from octop.infra.utils.paths import PathLayout +from octop.modules.org_os.service import org_module_from_paths +from octop.modules.org_os.sidecar_launch import restart_sidecar +from tests.support.app import ensure_control_plane_bound +from tests.support.auth import TEST_PASSWORD +from tests.support.harness import patch_harness + + +def _snapshot(root: Path) -> dict[str, tuple[str, int]]: + found: dict[str, tuple[str, int]] = {} + if not root.exists(): + return found + for path in root.rglob("*"): + if not path.is_file(): + continue + stat = path.stat() + digest = hashlib.sha256(path.read_bytes()).hexdigest() + found[path.relative_to(root).as_posix()] = (digest, stat.st_mtime_ns) + return found + + +def _seed_profile(root: Path) -> None: + workspace = root / ".octop" / "workspaces" / "main" + workspace.mkdir(parents=True) + (workspace / "SOUL.md").write_text("user soul\n", encoding="utf-8") + (workspace / "manifest.json").write_text("{}\n", encoding="utf-8") + (workspace / "memory.sqlite").write_bytes(b"sqlite") + (workspace / "memory.sqlite-wal").write_bytes(b"wal") + (workspace / "memory.sqlite-shm").write_bytes(b"shm") + skill_a = workspace / "skills" / "one" + skill_b = workspace / ".octop" / "skills" / "two" + skill_a.mkdir(parents=True) + skill_b.mkdir(parents=True) + (skill_a / "SKILL.md").write_text("# one\n", encoding="utf-8") + (skill_b / "SKILL.md").write_text("# two\n", encoding="utf-8") + (skill_b / "manage_skills.py").write_text("print('keep')\n", encoding="utf-8") + freeos = root / ".freeos" + freeos.mkdir() + (freeos / "config.json").write_text("{}\n", encoding="utf-8") + (freeos / "octop.db").write_bytes(b"db") + + +class _QuietProc: + pid = 0 + + def poll(self) -> int: + return 0 + + def wait(self, timeout: float | None = None) -> int: + return 0 + + def __enter__(self) -> _QuietProc: + return self + + def __exit__(self, *args: object) -> None: + return None + + +async def test_first_start_with_home_does_not_touch_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + sentinel = tmp_path / "sentinel-profile" + isolated = tmp_path / "isolated-home" + _seed_profile(sentinel) + before = _snapshot(sentinel) + + monkeypatch.setenv("HOME", str(sentinel)) + monkeypatch.setenv("USERPROFILE", str(sentinel)) + monkeypatch.setenv("FREEOS_HOME", str(isolated)) + monkeypatch.delenv("OCTOP_HOME", raising=False) + monkeypatch.delenv("OCTOP_DESKTOP", raising=False) + monkeypatch.delenv("FREEOS_DESKTOP", raising=False) + monkeypatch.setattr( + "octop.infra.agents.providers.llamacpp_runtime.find_llama_server", + lambda: None, + ) + + assert PathLayout.from_env().root.resolve() == isolated.resolve() + log_path = _log_path() + assert log_path.is_relative_to(isolated.resolve()) + + with patch_harness(None): + server = OctopServer() + await server.start() + await ensure_control_plane_bound(server) + if server.app_runtime is not None: + await server.app_runtime.proactive_scheduler.shutdown() + server.app_runtime.proactive_scheduler.suspend() + app = build_app(server) + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + assert (isolated / WIZARD_FILE_NAME).is_file() + assert not (sentinel / WIZARD_FILE_NAME).exists() + password = read_password(isolated) + assert password + token = ( + await client.post("/api/setup/verify-password", json={"password": password}) + ).json()["wizard_token"] + admin = await client.post( + "/api/setup/initial-admin", + json={"username": "admin", "password": TEST_PASSWORD}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert admin.status_code == 201, admin.text + finished = await client.post( + "/api/setup/finish", + json={"provider_draft": None}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert finished.status_code == 200, finished.text + auth = { + "Authorization": f"Bearer {admin.json()['access_token']}", + } + created = await client.post( + "/api/admin/providers", + headers=auth, + json={ + "name": "Ollama", + "kind": "openai", + "base_url": "http://127.0.0.1:11434/v1", + "api_key": "ollama", + "models": [ + { + "id": "llama3.2", + "name": "llama3.2", + "enabled": True, + "input": ["text"], + } + ], + }, + ) + assert created.status_code in {200, 201}, created.text + user = server.user_manager.get("admin") + assert user is not None + prepared = await prepare_dashboard_turn( + server, + agent_id="main", + user=user, + turn=ChatTurnBody( + messages=[{"role": "user", "content": "hello"}], + default_model="Ollama/llama3.2", + ), + ) + assert prepared.model_ref == "Ollama/llama3.2" + + service = org_module_from_paths(server.paths) + service.set_enabled(True) + with patch( + "octop.modules.org_os.sidecar_launch.subprocess.Popen", + return_value=_QuietProc(), + ): + restart_sidecar(service, wait=0.4) + finally: + await server.stop() + + workspace = isolated / ".octop" / "workspaces" / "main" + assert (workspace / "SOUL.md").is_file() + assert _snapshot(sentinel) == before + assert not any(sentinel.rglob("*.tmp")) diff --git a/tests/integration/test_invites_api.py b/tests/integration/test_invites_api.py index 0a4d1adb..f30e004c 100644 --- a/tests/integration/test_invites_api.py +++ b/tests/integration/test_invites_api.py @@ -5,7 +5,7 @@ import json from octop.infra.agents.default_agent import default_home_local_backend -from octop.infra.utils.host_dirs import host_home_dir, host_path_text +from octop.infra.utils.host_dirs import host_path_text async def test_invite_create_list_redeem_and_one_time(env) -> None: @@ -72,7 +72,7 @@ async def test_invite_create_list_redeem_and_one_time(env) -> None: assert bob_row is not None bob_cfg = json.loads(bob_row.config_json or "{}") assert bob_cfg["backend"] == default_home_local_backend() - assert bob_cfg["backend"]["root_dir"] == host_path_text(host_home_dir()) + assert bob_cfg["backend"]["root_dir"] == host_path_text(srv.paths.root) assert bob_cfg["workspace_dir"] == f"/.octop/workspaces/{bob_agents[0]['agent_id']}" again = await c.post( diff --git a/tests/integration/test_postgresql_control_plane.py b/tests/integration/test_postgresql_control_plane.py index 9ddba0dc..554b2fd2 100644 --- a/tests/integration/test_postgresql_control_plane.py +++ b/tests/integration/test_postgresql_control_plane.py @@ -267,7 +267,7 @@ async def test_setup_database_postgresql_bind(tmp_octop_home: Path) -> None: assert status.json()["database_bound"] is True assert status.json()["database_driver"] == "postgresql" - pw = read_password(tmp_octop_home.parent) + pw = read_password(tmp_octop_home) assert pw is not None verified = await client.post("/api/setup/verify-password", json={"password": pw}) assert verified.status_code == 200, verified.text diff --git a/tests/integration/test_setup_bootstrap.py b/tests/integration/test_setup_bootstrap.py index 3a123007..db4ac13d 100644 --- a/tests/integration/test_setup_bootstrap.py +++ b/tests/integration/test_setup_bootstrap.py @@ -8,6 +8,7 @@ from octop.infra.server import OctopServer from octop.infra.setup.password_file import read_password +from octop.infra.utils.host_dirs import host_path_text from tests.support.app import write_octop_config from tests.support.auth import auth_header, bootstrap_admin @@ -41,7 +42,7 @@ async def test_bootstrap_creates_default_main_agent(patched_app_client: Any) -> async def test_main_not_created_until_finish(patched_app_client: Any) -> None: c, _srv, home = patched_app_client - pw = read_password(home.parent) + pw = read_password(home) assert pw is not None tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] admin = ( @@ -83,7 +84,8 @@ async def test_start_writes_wizard_password_when_no_users(tmp_octop_home: Path) srv = OctopServer(home=tmp_octop_home) await srv.start() try: - assert (tmp_octop_home.parent / "octop-login.txt").exists() + assert (tmp_octop_home / "octop-login.txt").exists() + assert not (tmp_octop_home.parent / "octop-login.txt").exists() finally: await srv.stop() @@ -94,6 +96,7 @@ async def test_start_skips_wizard_password_when_disabled(tmp_octop_home: Path) - await srv.start() try: assert not (tmp_octop_home / "octop-login.txt").exists() + assert not (tmp_octop_home.parent / "octop-login.txt").exists() finally: await srv.stop() @@ -102,7 +105,7 @@ async def test_main_agent_seeds_general_assistant_workspace(patched_app_client: """Finish creates main with expert files even when no provider is configured yet.""" c, _srv, home = patched_app_client await bootstrap_admin(c, home) - ws = home / "workspaces" / "main" + ws = home / ".octop" / "workspaces" / "main" assert (ws / "SOUL.md").is_file() # New agents use system_files_path=.octop — BackendWorkspace remaps skills/. assert (ws / ".octop" / "skills" / "octop-assistant" / "SKILL.md").is_file() @@ -118,7 +121,7 @@ async def test_main_agent_uses_home_scoped_backend(patched_app_client: Any) -> N cfg = json.loads(row.config_json or "{}") assert cfg["backend"] == { "type": "local_shell", - "root_dir": home.parent.resolve().as_posix(), + "root_dir": host_path_text(home), "virtual_mode": True, } assert cfg["workspace_dir"] == "/.octop/workspaces/main" diff --git a/tests/integration/test_setup_database.py b/tests/integration/test_setup_database.py index 45bc93ee..e90821be 100644 --- a/tests/integration/test_setup_database.py +++ b/tests/integration/test_setup_database.py @@ -83,7 +83,7 @@ async def test_deferred_verify_password_then_bind(tmp_octop_home: Path) -> None: assert health.status_code == 200 assert health.json()["db"] is False - pw = read_password(tmp_octop_home.parent) + pw = read_password(tmp_octop_home) assert pw is not None verified = await client.post("/api/setup/verify-password", json={"password": pw}) assert verified.status_code == 200, verified.text diff --git a/tests/integration/test_setup_wizard.py b/tests/integration/test_setup_wizard.py index e05b9d80..f1ea96d7 100644 --- a/tests/integration/test_setup_wizard.py +++ b/tests/integration/test_setup_wizard.py @@ -26,7 +26,7 @@ async def env(patched_app_client): async def test_verify_password_returns_token_on_match(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) assert pw is not None r = await c.post("/api/setup/verify-password", json={"password": pw}) assert r.status_code == 200 @@ -66,7 +66,7 @@ async def test_initial_admin_requires_wizard_token(env: Any) -> None: async def test_initial_admin_succeeds_with_token(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.post( "/api/setup/initial-admin", @@ -77,12 +77,12 @@ async def test_initial_admin_succeeds_with_token(env: Any) -> None: body = r.json() assert isinstance(body["access_token"], str) # The wizard password file is kept permanently, even after setup completes. - assert (Path.home() / WIZARD_FILE_NAME).exists() + assert (home / WIZARD_FILE_NAME).exists() async def test_initial_admin_rejects_weak_password(env: Any) -> None: - c, _srv, _home = env - pw = read_password(Path.home()) + c, _srv, home = env + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.post( "/api/setup/initial-admin", @@ -94,8 +94,8 @@ async def test_initial_admin_rejects_weak_password(env: Any) -> None: async def test_initial_admin_respects_locale_body(env: Any) -> None: - c, srv, _home = env - pw = read_password(Path.home()) + c, srv, home = env + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.post( "/api/setup/initial-admin", @@ -119,7 +119,7 @@ async def test_status_reports_wizard_password_exists(env: Any) -> None: assert body["setup_required"] is True assert body["wizard_password_required"] is True assert body["wizard_password_exists"] is True - assert body["wizard_password_path"] == str(Path.home() / WIZARD_FILE_NAME) + assert body["wizard_password_path"] == str(home / WIZARD_FILE_NAME) assert body["desktop"] is False assert body["has_providers"] is False @@ -148,7 +148,7 @@ async def test_validate_token_rejects_missing_header(env: Any) -> None: async def test_validate_token_accepts_fresh_token(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.get( "/api/setup/validate-token", @@ -179,7 +179,7 @@ async def test_finish_requires_wizard_token(env: Any) -> None: async def test_finish_returns_ok_with_valid_token(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.post( "/api/setup/finish", @@ -192,7 +192,7 @@ async def test_finish_returns_ok_with_valid_token(env: Any) -> None: async def test_finish_works_after_admin_created(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] await c.post( "/api/setup/initial-admin", @@ -210,7 +210,7 @@ async def test_finish_works_after_admin_created(env: Any) -> None: async def test_validate_token_still_valid_after_admin_created(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] await c.post( "/api/setup/initial-admin", @@ -241,7 +241,7 @@ async def test_test_provider_requires_wizard_token(env: Any) -> None: async def test_test_provider_returns_error_for_bad_key(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.post( "/api/setup/test-provider", @@ -259,7 +259,7 @@ async def test_test_provider_returns_error_for_bad_key(env: Any) -> None: async def test_resume_wizard_after_admin_created(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] await c.post( "/api/setup/initial-admin", @@ -275,7 +275,7 @@ async def test_resume_wizard_after_admin_created(env: Any) -> None: async def test_test_provider_accepts_admin_jwt_after_admin_created(env: Any) -> None: c, srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] admin = ( await c.post( @@ -302,7 +302,7 @@ async def test_test_provider_accepts_admin_jwt_after_admin_created(env: Any) -> async def test_finish_saves_provider_with_admin_jwt(env: Any) -> None: c, srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] admin = ( await c.post( @@ -363,7 +363,7 @@ async def test_finish_saves_provider_with_admin_jwt(env: Any) -> None: async def test_setup_410_after_admin_exists(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] await c.post( "/api/setup/initial-admin", @@ -371,11 +371,11 @@ async def test_setup_410_after_admin_exists(env: Any) -> None: headers={"Authorization": f"Bearer {tok}"}, ) # Plant a stale file to verify the 410 guard does not touch it. - (Path.home() / WIZARD_FILE_NAME).write_text("stale\n", encoding="utf-8") + (home / WIZARD_FILE_NAME).write_text("stale\n", encoding="utf-8") r = await c.post("/api/setup/verify-password", json={"password": "stale"}) assert r.status_code == 410 # The wizard password file is never deleted by the app, even on the 410 path. - assert (Path.home() / WIZARD_FILE_NAME).exists() + assert (home / WIZARD_FILE_NAME).exists() # ─── lockdown middleware ─────────────────────────────────────────── @@ -403,7 +403,7 @@ async def test_lockdown_allows_health_path(env: Any) -> None: async def test_lockdown_lifts_after_admin_created(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] await c.post( "/api/setup/initial-admin", @@ -417,7 +417,7 @@ async def test_lockdown_lifts_after_admin_created(env: Any) -> None: async def test_finish_rejects_invalid_token_after_admin_exists(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] await c.post( "/api/setup/initial-admin", @@ -434,7 +434,7 @@ async def test_finish_rejects_invalid_token_after_admin_exists(env: Any) -> None async def test_finish_rejects_empty_cloud_api_key(env: Any) -> None: c, srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] admin = ( await c.post( @@ -471,7 +471,7 @@ async def test_finish_ignores_cloud_api_key_environment( monkeypatch.setenv("OPENAI_API_KEY", "sk-" + "b" * 32) monkeypatch.setenv("LLM_API_KEY", "sk-" + "c" * 32) c, srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] admin = ( await c.post( @@ -503,7 +503,7 @@ async def test_finish_ignores_cloud_api_key_environment( async def test_test_provider_rejects_empty_cloud_api_key(env: Any) -> None: c, _srv, home = env - pw = read_password(Path.home()) + pw = read_password(home) tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] r = await c.post( "/api/setup/test-provider", diff --git a/tests/support/auth.py b/tests/support/auth.py index e0b498e5..c0b9ae80 100644 --- a/tests/support/auth.py +++ b/tests/support/auth.py @@ -14,7 +14,7 @@ def _wizard_password_home(home: Path) -> Path: - """Resolve wizard password dir: file lives at ``~/octop-login.txt``, not under ``~/.octop/``.""" + """Resolve the wizard password dir inside the product home.""" for cand in (home, home.parent, Path.home()): if (cand / WIZARD_FILE_NAME).exists(): return cand diff --git a/tests/unit/agents/test_default_agent.py b/tests/unit/agents/test_default_agent.py index 5e6395fd..b64cb574 100644 --- a/tests/unit/agents/test_default_agent.py +++ b/tests/unit/agents/test_default_agent.py @@ -15,6 +15,8 @@ ) from octop.infra.agents.experts.catalog import ExpertCatalog, default_library_root from octop.infra.errors import OctopError +from octop.infra.utils.host_dirs import host_path_text +from octop.infra.utils.paths import PathLayout @pytest.fixture(scope="module") @@ -67,7 +69,8 @@ async def test_bootstrap_creates_general_assistant( assert spec.agent_id is None assert spec.template_name == DEFAULT_EXPERT_ID assert spec.config["backend"] == default_home_local_backend() - assert spec.config["backend"]["root_dir"] == _isolated_user_home.resolve().as_posix() + assert spec.config["backend"]["root_dir"] == host_path_text(PathLayout.from_env().root) + assert spec.config["backend"]["root_dir"] != _isolated_user_home.resolve().as_posix() assert registry.create.await_args.kwargs["defer_bootstrap"] is True @@ -89,7 +92,8 @@ async def test_bootstrap_main_uses_home_backend( spec = registry.create.await_args.args[0] assert spec.config["backend"] == default_home_local_backend() - assert spec.config["backend"]["root_dir"] == _isolated_user_home.resolve().as_posix() + assert spec.config["backend"]["root_dir"] == host_path_text(PathLayout.from_env().root) + assert spec.config["backend"]["root_dir"] != _isolated_user_home.resolve().as_posix() async def test_bootstrap_requires_catalog() -> None: diff --git a/tests/unit/agents/test_home_write_guard.py b/tests/unit/agents/test_home_write_guard.py new file mode 100644 index 00000000..5c541226 --- /dev/null +++ b/tests/unit/agents/test_home_write_guard.py @@ -0,0 +1,63 @@ +"""Product-home write guards and Windows path containment.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from octop.infra.agents.workspace_dir import ( + path_is_within, + seed_workspace_dir_on_create, +) +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.paths import PathLayout + + +def _scoped(root: Path) -> dict: + return { + "backend": { + "type": "local_shell", + "root_dir": str(root), + "virtual_mode": True, + } + } + + +def test_windows_profile_workspace_is_outside_isolated_home() -> None: + profile = r"C:\Users\alice\.octop\workspaces\main" + isolated = r"D:\freeos-data" + assert path_is_within(profile, isolated) is False + assert path_is_within(r"D:\freeos-data\.octop\workspaces\main", isolated) is True + assert path_is_within(r"c:/Users/alice/.octop/workspaces/main", r"C:\Users\alice") is True + + +def test_refuse_preexisting_directory_outside_home(tmp_path: Path) -> None: + product = tmp_path / "freeos-home" + product.mkdir() + user = tmp_path / "Users" / "alice" + existing = user / ".octop" / "workspaces" / "main" + existing.mkdir(parents=True) + (existing / "SOUL.md").write_text("keep", encoding="utf-8") + (existing / "memory.sqlite").write_bytes(b"db") + + with pytest.raises(OctopError) as exc: + seed_workspace_dir_on_create(_scoped(user), paths=PathLayout(product), agent_id="main") + + assert exc.value.code == ErrorCode.HOME_WRITE_REFUSED + assert (existing / "SOUL.md").read_text(encoding="utf-8") == "keep" + assert (existing / "memory.sqlite").read_bytes() == b"db" + + +def test_refuse_seed_over_existing_data_inside_home(tmp_path: Path) -> None: + product = tmp_path / "freeos-home" + existing = product / ".octop" / "workspaces" / "main" + existing.mkdir(parents=True) + (existing / "SOUL.md").write_text("keep", encoding="utf-8") + + with pytest.raises(OctopError) as exc: + seed_workspace_dir_on_create(_scoped(product), paths=PathLayout(product), agent_id="main") + + assert exc.value.code == ErrorCode.AGENT_SEED_EXISTS + assert (existing / "SOUL.md").read_text(encoding="utf-8") == "keep" + assert not (existing / "manifest.json").exists() diff --git a/tests/unit/test_wizard_password.py b/tests/unit/test_wizard_password.py index 5f0f9f5f..c9fa3a78 100644 --- a/tests/unit/test_wizard_password.py +++ b/tests/unit/test_wizard_password.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import stat from pathlib import Path @@ -52,6 +53,22 @@ def test_read_password_returns_none_when_missing(tmp_path: Path) -> None: assert read_password(tmp_path) is None +def test_boot_self_heal_logs_created_or_reused_without_password( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + with caplog.at_level(logging.INFO): + created = boot_self_heal(tmp_path, user_count=0) + assert created is not None + assert created not in caplog.text + assert "wizard password file created" in caplog.text + caplog.clear() + with caplog.at_level(logging.INFO): + reused = boot_self_heal(tmp_path, user_count=0) + assert reused == created + assert created not in caplog.text + assert "wizard password file reused" in caplog.text + + def test_boot_self_heal_generates_when_no_users(tmp_path: Path) -> None: pw = boot_self_heal(tmp_path, user_count=0) assert pw is not None From 3fe1cd275424257dd9f656895d886de302ecb0a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 07:35:07 +0000 Subject: [PATCH 17/39] fix(models): note missing llama.cpp when another provider is up The built-in runtime warning stays red only when no provider answers a connectivity check. Ollama or another reachable base URL turns it into a grey note, and an Ollama model can still be selected for chat. Co-authored-by: haoboy007-sudo --- dashboard/src/api/modules/localModels.ts | 2 + .../components/LocalHardwarePanel.test.tsx | 45 +++++++++++++++++++ .../Models/components/LocalHardwarePanel.tsx | 38 +++++++++++----- src/octop/api/routers/local_models.py | 15 ++++++- .../infra/agents/providers/local_probe.py | 23 ++++++++++ tests/unit/test_local_probe.py | 38 +++++++++++++++- 6 files changed, 149 insertions(+), 12 deletions(-) diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts index edc99ef2..c89652f4 100644 --- a/dashboard/src/api/modules/localModels.ts +++ b/dashboard/src/api/modules/localModels.ts @@ -67,6 +67,8 @@ export interface LocalProbe { deps?: LocalDep[]; installed: LocalInstalledModel[]; recommended: LocalRecommendedModel[]; + /** True after an HTTP connectivity check (Ollama, llama.cpp, or a provider base URL). */ + reachable_provider?: boolean; default_ref?: string; default_provider_name?: string; default_model?: string; diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx index 18d19996..afa97184 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.test.tsx @@ -306,4 +306,49 @@ describe("", () => { "FreeOS llama.cpp", ); }); + + it("uses a grey note when a reachable provider exists and llama.cpp is missing", async () => { + probe.mockResolvedValue({ + ...installedStopped, + hardware: { + ...installedStopped.hardware, + ollama_reachable: true, + llamacpp_binary: false, + llamacpp_reachable: false, + }, + reachable_provider: true, + deps: [ + { + id: "llamacpp", + kind: "bundled_runtime", + automatable: false, + method: "repair_freeos", + }, + ], + }); + renderPanel(); + const note = await screen.findByText("models.localBuiltinMissing", { + exact: false, + }); + expect(note.closest(".ant-alert-error")).toBeNull(); + }); + + it("keeps a red error when nothing is reachable and llama.cpp is missing", async () => { + probe.mockResolvedValue({ + ...installedStopped, + hardware: { + ...installedStopped.hardware, + ollama_installed: false, + ollama_binary: false, + ollama_reachable: false, + llamacpp_binary: false, + llamacpp_reachable: false, + }, + reachable_provider: false, + deps: [{ id: "llamacpp", kind: "bundled_runtime", automatable: false }], + }); + renderPanel(); + const note = await screen.findByText("models.localBuiltinMissing"); + expect(note.closest(".ant-alert-error")).not.toBeNull(); + }); }); diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index 80d0f4cd..0fb4fc01 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -655,6 +655,9 @@ export function LocalHardwarePanel({ const ollamaUp = Boolean(hw?.ollama_reachable); const llamaCppInstalled = Boolean(hw?.llamacpp_binary); const llamaCppUp = Boolean(hw?.llamacpp_reachable); + const providerReachable = Boolean( + probe?.reachable_provider || hw?.ollama_reachable || llamaCppUp, + ); const deps = probe?.deps ?? []; const models = useMemo( () => mergeModels(probe?.installed, scan?.found), @@ -699,7 +702,15 @@ export function LocalHardwarePanel({ : t("organization.off")} {t("models.localBuiltinRuntime")}{" "} {llamaCppUp ? t("organization.on") : t("organization.off")} @@ -751,15 +762,22 @@ export function LocalHardwarePanel({ } /> )} - {deps.some((dep) => dep.id === "llamacpp") && ( - - )} + {deps.some((dep) => dep.id === "llamacpp") && + (providerReachable ? ( + + {`${t("models.localBuiltinMissing")} — ${t( + "models.localBuiltinMissingHelp", + )}`} + + ) : ( + + ))}
openXYOS

Open-source operating system for human–agent organizations.

Apache License 2.0 · Built in the open
- {demoOpen &&
setDemoOpen(false)}>
e.stopPropagation()}>{demoPhase === "model" ? <>

{tx("接入大模型,体验完整智能能力", "Connect a model to experience full intelligence")}

{tx("优先使用本机 Ollama;云厂商需要你自己的 API Key,安装包不会预填密钥。密钥保存到当前本地演示租户,页面不会回显原文;之后可在“系统设置 → AI 大模型”中更换。", "Prefer local Ollama first. Cloud providers need your own API key — installers never prefill one. The key is stored only in the current demo tenant and is never shown back in plain text; change it later in System Settings → AI Models.")}

{EXPERIENCE_MODELS.map(model => )}
{tx("Key 仅发送到当前 openXYOS 后端,不写入浏览器日志,也不会再次明文返回。", "The key is sent only to the current openXYOS backend, never logged in the browser, and never returned in plain text.")}
{loginState !== "idle" &&

{tx("正在登录演示租户并保存模型配置…", "Signing in to the demo tenant and saving model configuration…")}

}{loginError &&

{loginError}

}
: <>

{tx("选择测试视角", "Choose a demo perspective")}

{tx("使用本地演示租户进入真实系统。管理员可体验模块开关和名称编辑,普通员工只看到已授权能力。", "Enter the live system with the local demo tenant. Administrators can manage modules and labels; users see only authorized capabilities.")}

{loginState !== "idle" &&

{tx("正在连接演示环境…", "Connecting to the demo environment…")}

}{loginError &&

{loginError}

}}
} + {demoOpen &&
setDemoOpen(false)}>
e.stopPropagation()}>{demoPhase === "model" ? <>

{tx("接入大模型,体验完整智能能力", "Connect a model to experience full intelligence")}

{tx("优先使用本机 Ollama;云厂商需要你自己的 API Key,安装包不会预填密钥。密钥保存到当前本地演示租户,页面不会回显原文;之后可在“系统设置 → AI 大模型”中更换。", "Prefer local Ollama first. Cloud providers need your own API key — installers never prefill one. The key is stored only in the current demo tenant and is never shown back in plain text; change it later in System Settings → AI Models.")}

{EXPERIENCE_MODELS.map(model => )}
{tx("Key 仅发送到当前 openXYOS 后端,不写入浏览器日志,也不会再次明文返回。", "The key is sent only to the current openXYOS backend, never logged in the browser, and never returned in plain text.")}
{loginState !== "idle" &&

{tx("正在登录演示租户并保存模型配置…", "Signing in to the demo tenant and saving model configuration…")}

}{loginError &&

{loginError}

}
: <>

{tx("选择测试视角", "Choose a demo perspective")}

{tx("使用本地演示租户进入真实系统。管理员可体验模块开关和名称编辑,普通员工只看到已授权能力。", "Enter the live system with the local demo tenant. Administrators can manage modules and labels; users see only authorized capabilities.")}

{loginState !== "idle" &&

{tx("正在连接演示环境…", "Connecting to the demo environment…")}

}{loginError &&

{loginError}

}}
} ; } diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 8d4067a9..8e729130 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -32,7 +32,7 @@ "audit:legacy-knowledge-files": "tsx scripts/audit-legacy-knowledge-files.ts", "ci": "npm run lint && npm run typecheck && npm test && npm run verify:open-source && npm run build", "start": "node --import tsx backend/server.ts", - "seed": "tsx backend/seed.ts", + "seed": "FREEOS_DEV_BUILD=1 tsx backend/seed.ts", "verify:open-source": "node scripts/verify-open-source.mjs", "test:module-settings": "tsx scripts/test-module-settings.ts", "test:agent-studio": "tsx scripts/test-agent-studio.ts", diff --git a/modules/openxyos/scripts/test-no-release-bootstrap.ts b/modules/openxyos/scripts/test-no-release-bootstrap.ts index f3c70f73..c811c220 100644 --- a/modules/openxyos/scripts/test-no-release-bootstrap.ts +++ b/modules/openxyos/scripts/test-no-release-bootstrap.ts @@ -36,7 +36,7 @@ async function main() { colleague: dbGet("SELECT id FROM users WHERE email = ?", ["user@demo.com"]), }); const before = snapshot(); - for (let i = 0; i < 3; i += 1) seedDatabase(); + for (let i = 0; i < 3; i += 1) await seedDatabase(); assert.deepEqual(snapshot(), before); assert.equal(before.demo, undefined); assert.equal(before.users.password_hash, "hash-keep"); diff --git a/tests/unit/test_org_release_bundle.py b/tests/unit/test_org_release_bundle.py new file mode 100644 index 00000000..934b301f --- /dev/null +++ b/tests/unit/test_org_release_bundle.py @@ -0,0 +1,70 @@ +"""The shipped organization tree must not contain the demo seed module.""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +ORG = REPO / "modules" / "openxyos" +BUNDLE = REPO / "desktop" / "portable" / "bundle-org-sidecar.sh" + +SKIP_DIRS = { + "node_modules", + "dist", + ".git", + ".github", + "backups", + "dist-backup-20260626", + "V0.5", + ".freeos", + ".octop", +} +SEED_MARKERS = ( + "openxyos-demo-2026", + "ensureLocalBootstrap", + "demo@demo.com", + "user@demo.com", +) +# Cleanup and the UI-tenant denylist name the accounts they must not prefer. +# They do not insert demo rows. +ALLOW = { + ORG / "backend" / "upgrade-cleanup.ts", + ORG / "backend" / "routes" / "freeos-bridge.ts", +} + + +def _shipped_files() -> list[Path]: + files: list[Path] = [] + for path in ORG.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(ORG) + if any(part in SKIP_DIRS for part in relative.parts): + continue + if relative.parts[:2] == ("backend", "dev"): + continue + if relative.parts[:1] == ("scripts",) and path.name.startswith("test-"): + continue + files.append(path) + return files + + +def test_bundle_script_excludes_demo_seed() -> None: + text = BUNDLE.read_text(encoding="utf-8") + assert "--exclude backend/dev" in text + assert "scripts/test-*" in text + assert 'dropped.add("dev")' in text + + +def test_release_tree_has_no_demo_seed_strings() -> None: + hits: list[str] = [] + for path in _shipped_files(): + if path.suffix not in {".ts", ".tsx", ".js", ".mjs", ".sh", ".json", ".ps1"}: + continue + if path in ALLOW: + continue + text = path.read_text(encoding="utf-8", errors="replace") + for marker in SEED_MARKERS: + if marker in text: + hits.append(f"{path.relative_to(REPO)}: {marker}") + assert hits == [] From 2073c3c86ccf9caf6f468c2cf1688989d8902aad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:14:05 +0000 Subject: [PATCH 27/39] F6 plugin install: disable on uninstall and require paid-price confirmation Uninstall now sets the plugin status to disabled so the contract parser gate turns off, and a paid install stays disabled until the client confirms the exact price. Both actions write an audit row. The catalog row is kept. Co-authored-by: haoboy007-sudo --- modules/openxyos/backend/routes/plugins.ts | 87 ++++++++++++++--- modules/openxyos/package.json | 3 +- .../openxyos/scripts/test-plugin-install.ts | 95 +++++++++++++++++++ 3 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 modules/openxyos/scripts/test-plugin-install.ts diff --git a/modules/openxyos/backend/routes/plugins.ts b/modules/openxyos/backend/routes/plugins.ts index e4121dc6..eb244689 100644 --- a/modules/openxyos/backend/routes/plugins.ts +++ b/modules/openxyos/backend/routes/plugins.ts @@ -5,6 +5,33 @@ import { authenticate, AuthRequest } from "../middleware"; export const pluginRoutes = Router(); pluginRoutes.use(authenticate); +const FREE_PRICES = new Set(["", "免费", "免费额度", "free", "free tier", "0"]); + +function isPaidPrice(price: unknown): boolean { + return !FREE_PRICES.has(String(price || "").trim().toLowerCase()); +} + +function writePluginAudit(tenantId: number, userId: number, action: string, pluginId: number, detail: string): void { + dbRun( + `CREATE TABLE IF NOT EXISTS workflow_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id INTEGER, + old_value TEXT, + new_value TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + ); + dbRun( + `INSERT INTO workflow_audit_log (tenant_id, user_id, action, target_type, target_id, new_value) + VALUES (?, ?, ?, 'plugin', ?, ?)`, + [tenantId, userId, action, pluginId, detail], + ); +} + pluginRoutes.get("/", (req: AuthRequest, res) => { try { const { category, search, sort } = req.query; @@ -75,34 +102,66 @@ pluginRoutes.post("/:id/install", (req: AuthRequest, res) => { ) as any; if (!plugin) return res.status(404).json({ success: false, error: "插件不存在" }); + if (isPaidPrice(plugin.price)) { + const confirmed = req.body?.confirm_price === true && String(req.body?.price || "") === String(plugin.price); + if (!confirmed) { + return res.status(409).json({ + success: false, + code: "PRICE_CONFIRMATION_REQUIRED", + error: "付费插件需要明确确认价格后才能启用", + data: { price: plugin.price, enabled: false }, + }); + } + } + const already = dbGet( "SELECT id FROM tenant_plugin_installs WHERE tenant_id = ? AND plugin_id = ?", [req.user!.tenant_id, plugin.id] ); - if (already) return res.json({ success: true, data: { installed: true, message: "已安装" } }); - - dbRun( - "INSERT INTO tenant_plugin_installs (tenant_id, plugin_id) VALUES (?, ?)", - [req.user!.tenant_id, plugin.id] - ); - dbRun("UPDATE plugins SET install_count = install_count + 1 WHERE id = ?", [plugin.id]); - - // 如果安装的是AI合同智能解析插件,自动启用 - if (plugin.slug === "ai合同智能解析") { - dbRun("UPDATE plugins SET status = 'active' WHERE id = ? AND tenant_id = ?", [plugin.id, req.user!.tenant_id]); + if (!already) { + dbRun( + "INSERT INTO tenant_plugin_installs (tenant_id, plugin_id) VALUES (?, ?)", + [req.user!.tenant_id, plugin.id] + ); + dbRun("UPDATE plugins SET install_count = install_count + 1 WHERE id = ?", [plugin.id]); } + dbRun("UPDATE plugins SET status = 'active' WHERE id = ? AND tenant_id = ?", [plugin.id, req.user!.tenant_id]); + writePluginAudit( + req.user!.tenant_id, + req.user!.id, + "f6_install_plugin", + plugin.id, + `enabled ${plugin.slug || plugin.name} after ${isPaidPrice(plugin.price) ? "price confirmation" : "free install"}`, + ); - res.json({ success: true, data: { installed: true, message: "安装成功" } }); + res.json({ success: true, data: { installed: true, enabled: true, message: "安装成功" } }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); // 卸载插件 pluginRoutes.post("/:id/uninstall", (req: AuthRequest, res) => { try { + const pluginId = parseInt(req.params.id); + const plugin = dbGet( + "SELECT id, slug, name FROM plugins WHERE id = ? AND tenant_id = ?", + [pluginId, req.user!.tenant_id] + ) as { id: number; slug?: string; name?: string } | undefined; + if (!plugin) return res.status(404).json({ success: false, error: "插件不存在" }); dbRun( "DELETE FROM tenant_plugin_installs WHERE tenant_id = ? AND plugin_id = ?", - [req.user!.tenant_id, parseInt(req.params.id)] + [req.user!.tenant_id, plugin.id] + ); + dbRun( + "UPDATE plugins SET status = 'disabled' WHERE id = ? AND tenant_id = ?", + [plugin.id, req.user!.tenant_id] + ); + writePluginAudit( + req.user!.tenant_id, + req.user!.id, + "f6_uninstall_plugin", + plugin.id, + `disabled ${plugin.slug || plugin.name}; catalog row kept`, ); - res.json({ success: true, data: { installed: false } }); + res.json({ success: true, data: { installed: false, enabled: false, status: "disabled" } }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 8e729130..5837b0d9 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -11,8 +11,9 @@ "postinstall": "node scripts/patch-express-types.cjs", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", - "test": "npm run test:db-persistence && npm run test:no-release-bootstrap && npm run test:f10-upgrade-cleanup && 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:doc-parser && 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:no-release-bootstrap && npm run test:f10-upgrade-cleanup && npm run test:plugin-install && 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:doc-parser && 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:f10-upgrade-cleanup": "tsx scripts/test-f10-upgrade-cleanup.ts", + "test:plugin-install": "tsx scripts/test-plugin-install.ts", "test:no-release-bootstrap": "tsx scripts/test-no-release-bootstrap.ts", "test:db-persistence": "tsx scripts/test-db-persistence.ts", "test:runtime-config": "tsx scripts/test-runtime-config.ts", diff --git a/modules/openxyos/scripts/test-plugin-install.ts b/modules/openxyos/scripts/test-plugin-install.ts new file mode 100644 index 00000000..1572ce37 --- /dev/null +++ b/modules/openxyos/scripts/test-plugin-install.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import express from "express"; + +async function main() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "freeos-plugin-")); + process.env.NODE_ENV = "development"; + process.env.JWT_SECRET = "plugin-install-test-secret-at-least-32-characters"; + process.env.COOKIE_SECRET = "plugin-install-cookie-secret-at-least-32-chars"; + process.env.DATABASE_PATH = path.join(dir, "xiongyuan.db"); + delete process.env.FREEOS_DEV_BUILD; + + const { initDatabase, dbGet, dbRun } = await import("../backend/db"); + const { pluginRoutes } = await import("../backend/routes/plugins"); + const { contractRoutes } = await import("../backend/routes/contracts"); + const { signToken } = await import("../backend/middleware"); + await initDatabase(); + + dbRun("INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (1, 'Acme', 'acme', 'active', 'community')"); + const userId = Number(dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'admin', 1)", + ["owner@example.com", "hash", "Owner"], + ).lastInsertRowid); + const pluginId = Number(dbRun( + `INSERT INTO plugins (tenant_id, name, slug, category, description, version, author, price, tags, install_count, rating, status) + VALUES (1, 'AI合同智能解析', 'ai合同智能解析', '法务合规', 'paid', '1.0.0', '雄元科技', '¥99/月', 'AI', 0, 5, 'active')`, + ).lastInsertRowid); + dbRun("INSERT INTO tenant_plugin_installs (tenant_id, plugin_id) VALUES (1, ?)", [pluginId]); + + const token = signToken({ id: userId, email: "owner@example.com", nickname: "Owner", role: "admin", tenant_id: 1 }); + const app = express(); + app.use(express.json()); + app.use("/api/plugins", pluginRoutes); + app.use("/api/contracts", contractRoutes); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + async function call(url: string, method = "GET", body?: unknown) { + return fetch(`${base}${url}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + } + + try { + const removed = await call(`/api/plugins/${pluginId}/uninstall`, "POST"); + assert.equal(removed.status, 200); + const removedBody = await removed.json() as { data: { enabled: boolean; status: string } }; + assert.equal(removedBody.data.enabled, false); + assert.equal(removedBody.data.status, "disabled"); + assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [pluginId]).status, "disabled"); + assert.equal(dbGet("SELECT id FROM plugins WHERE id = ?", [pluginId]).id, pluginId); + assert.equal(dbGet("SELECT id FROM tenant_plugin_installs WHERE plugin_id = ?", [pluginId]), undefined); + const gated = await call("/api/contracts/approval/rules/parse", "POST"); + assert.equal(gated.status, 402); + + const listed = await call("/api/plugins"); + const listedBody = await listed.json() as { data: Array<{ id: number }> }; + assert.equal(listedBody.data.some((row) => row.id === pluginId), false); + + const refused = await call(`/api/plugins/${pluginId}/install`, "POST", {}); + assert.equal(refused.status, 409); + const refusedBody = await refused.json() as { code: string; data: { price: string; enabled: boolean } }; + assert.equal(refusedBody.code, "PRICE_CONFIRMATION_REQUIRED"); + assert.equal(refusedBody.data.enabled, false); + assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [pluginId]).status, "disabled"); + + const confirmed = await call(`/api/plugins/${pluginId}/install`, "POST", { confirm_price: true, price: "¥99/月" }); + assert.equal(confirmed.status, 200); + assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [pluginId]).status, "active"); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM workflow_audit_log WHERE action = 'f6_install_plugin'").c), 1); + + const again = await call(`/api/plugins/${pluginId}/uninstall`, "POST"); + assert.equal(again.status, 200); + assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [pluginId]).status, "disabled"); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM workflow_audit_log WHERE action = 'f6_uninstall_plugin'").c), 2); + console.log("plugin install confirmation and uninstall ok"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From 3c1a0703244fed162f1fe1ac85a22b187cc9a8d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:14:46 +0000 Subject: [PATCH 28/39] F7 audit: pin qs 6.16.2 and deepmerge 4.1.2 CI run 40838520982 failed npm audit, not the compile. qs 6.16.0 is inside GHSA-6pq8-jhm2-2vcw, and vite-plugin-pwa pulls deepmerge >=4.2.0 (GHSA-gcqx-w48m-w2h4) which has no patched 4.2 release. Overrides move both trees onto versions outside those ranges. Co-authored-by: haoboy007-sudo --- dashboard/package-lock.json | 19 ++++++++++--------- dashboard/package.json | 3 ++- modules/openxyos/package-lock.json | 26 +++++++++++++------------- modules/openxyos/package.json | 3 ++- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 25e4dd81..7d81acd5 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -3372,6 +3372,16 @@ } } }, + "node_modules/@rollup/plugin-node-resolve/node_modules/deepmerge": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.1.2.tgz", + "integrity": "sha512-9ZXh/8Fp+ggcscoRg324S1aNdGSCl2X1P8F1FPTHbtq3RAxy9ECbU2Wgy2J12r6x/O0BStDlkzcpKK/HptX+Kg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@rollup/plugin-replace": { "version": "6.0.3", "resolved": "https://mirrors.tencent.com/npm/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", @@ -6445,15 +6455,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://mirrors.tencent.com/npm/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://mirrors.tencent.com/npm/define-data-property/-/define-data-property-1.1.4.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index c5ea36b2..f66155e3 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -55,7 +55,8 @@ "@rollup/plugin-terser": "^1.0.0", "katex": "^0.19.0", "dompurify": "^3.4.16", - "vite": "^6.4.5" + "vite": "^6.4.5", + "deepmerge": "4.1.2" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/modules/openxyos/package-lock.json b/modules/openxyos/package-lock.json index 8d6c8323..c6e19d49 100644 --- a/modules/openxyos/package-lock.json +++ b/modules/openxyos/package-lock.json @@ -3593,6 +3593,16 @@ } } }, + "node_modules/@rollup/plugin-node-resolve/node_modules/deepmerge": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.1.2.tgz", + "integrity": "sha512-9ZXh/8Fp+ggcscoRg324S1aNdGSCl2X1P8F1FPTHbtq3RAxy9ECbU2Wgy2J12r6x/O0BStDlkzcpKK/HptX+Kg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@rollup/plugin-replace": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", @@ -7166,16 +7176,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", @@ -11384,9 +11384,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz", + "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 5837b0d9..0722e489 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -112,7 +112,8 @@ "node": ">=22.12" }, "overrides": { - "qs": "6.16.0", + "qs": "6.16.2", + "deepmerge": "4.1.2", "follow-redirects": "1.16.1", "vite": "^6.4.5", "katex": "^0.18.2", From 25468e4d3440c212d18c91135b56a87ee614b198 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:15:42 +0000 Subject: [PATCH 29/39] F9 backup: exercise install.sh upgrade backup and block NSIS overwrite without one install.sh can run the real pre-upgrade backup against a fake existing home and abort when that backup fails. The Windows NSIS overwrite path now runs the same full backup before copying files and aborts Setup when it fails. makensis itself is not executed here. Co-authored-by: haoboy007-sudo --- .../windows/nsis/backup-before-overwrite.ps1 | 45 +++++++++ desktop/src/build/windows/nsis/project.nsi | 2 + .../src/build/windows/nsis/wails_tools.nsh | 14 +++ scripts/install.sh | 81 +++++++++-------- tests/unit/test_upgrade_backup.py | 91 +++++++++++++++++++ 5 files changed, 197 insertions(+), 36 deletions(-) create mode 100644 desktop/src/build/windows/nsis/backup-before-overwrite.ps1 diff --git a/desktop/src/build/windows/nsis/backup-before-overwrite.ps1 b/desktop/src/build/windows/nsis/backup-before-overwrite.ps1 new file mode 100644 index 00000000..4714bf8e --- /dev/null +++ b/desktop/src/build/windows/nsis/backup-before-overwrite.ps1 @@ -0,0 +1,45 @@ +# Pre-install backup for an NSIS overwrite. Fresh installs (no database and no +# config) exit 0. An existing home must produce a full archive plus sha256 +# before Setup copies files; otherwise this script exits non-zero and Setup aborts. +# Not executed by the Linux CI runner. The NSIS section calls it before wails.files. +$ErrorActionPreference = "Stop" + +function Resolve-FreeOSHome { + if ($env:FREEOS_HOME) { return $env:FREEOS_HOME } + if ($env:OCTOP_HOME) { return $env:OCTOP_HOME } + $freeos = Join-Path $env:USERPROFILE ".freeos" + $legacy = Join-Path $env:USERPROFILE ".octop" + if (Test-Path $freeos) { return $freeos } + if (Test-Path $legacy) { return $legacy } + return $freeos +} + +$home = Resolve-FreeOSHome +$db = Join-Path $home "octop.db" +$config = Join-Path $home "config.json" +if (-not (Test-Path $db) -and -not (Test-Path $config)) { exit 0 } + +$stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ") +$backupDir = Join-Path $home "backups" +New-Item -ItemType Directory -Force -Path $backupDir | Out-Null +$out = Join-Path $backupDir "pre-upgrade-nsis-$stamp.tar.gz" + +$candidates = @( + (Join-Path $home "venv\Scripts\octop.exe"), + (Join-Path $home "portable\runtime\python.exe") +) +$octop = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $octop) { Write-Error "Upgrade aborted: backup CLI missing"; exit 2 } + +if ($octop.EndsWith("python.exe")) { + $launch = Join-Path $home "portable\launch.py" + if (-not (Test-Path $launch)) { Write-Error "Upgrade aborted: launch.py missing"; exit 2 } + & $octop $launch backup create --home $home --include-chats -o $out +} else { + & $octop backup create --home $home --include-chats -o $out +} +if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { Write-Error "Upgrade aborted: backup failed"; exit 1 } +$hash = (Get-FileHash -Algorithm SHA256 -Path $out).Hash.ToLower() +$leaf = Split-Path -Leaf $out +Set-Content -Encoding ascii -Path "$out.sha256" -Value "$hash $leaf" +exit 0 diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 4125fc5c..043432b0 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -135,6 +135,8 @@ Section !insertmacro wails.webview2runtime + !insertmacro wails.backupBeforeOverwrite + SetOutPath $INSTDIR !insertmacro wails.files diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index ddd5fb61..ba879d19 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -331,6 +331,20 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" ${EndIf} !macroend +# Overwrite install: full backup of an existing home before files are copied. +# A non-zero exit aborts Setup. Fresh installs with no database/config continue. +# Linux CI does not run makensis or this PowerShell path. +!macro wails.backupBeforeOverwrite + InitPluginsDir + File "/oname=$PLUGINSDIR\backup-before-overwrite.ps1" "backup-before-overwrite.ps1" + nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\backup-before-overwrite.ps1"' + Pop $0 + ${If} $0 != 0 + MessageBox MB_ICONSTOP|MB_OK "Upgrade aborted: backup failed (exit $0). Existing data was not overwritten." + Abort + ${EndIf} +!macroend + # Install/reinstall: stop a running shell so Setup can overwrite FreeOS.exe # and the next launch can refresh ~/.freeos/portable. Silent installs stop # without asking. LangString INSTALL_FREEOS_RUNNING is in project.nsi. diff --git a/scripts/install.sh b/scripts/install.sh index 56804724..8eda097c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -82,6 +82,43 @@ warn() { printf "${YELLOW}[freeos]${RESET} %s\n" "$*"; } error() { printf "${RED}[freeos]${RESET} %s\n" "$*" >&2; } die() { error "$@"; exit 1; } +_sha256_file() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" > "${file}.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" > "${file}.sha256" + else + return 1 + fi +} + +# Existing installs get a full archive before the package is replaced. +# A failed backup aborts the upgrade; the recorded sha256 is the checksum of that archive. +_backup_before_upgrade() { + local cli="$OCTOP_VENV/bin/octop" + if [ ! -x "$cli" ]; then + return 0 + fi + if [ ! -e "$OCTOP_HOME/octop.db" ] && [ ! -e "$OCTOP_HOME/config.json" ]; then + info "No existing database or config; skipping pre-upgrade backup" + return 0 + fi + mkdir -p "$OCTOP_HOME/backups" || die "Upgrade aborted: cannot create $OCTOP_HOME/backups" + local stamp out + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + out="$OCTOP_HOME/backups/pre-upgrade-${stamp}.tar.gz" + info "Full backup before upgrade: $out" + if ! "$cli" backup create --home "$OCTOP_HOME" --include-chats -o "$out"; then + die "Upgrade aborted: backup failed" + fi + if ! _sha256_file "$out"; then + die "Upgrade aborted: could not record sha256 for $out" + fi + info "Backup sha256 written to ${out}.sha256" +} + + # ── 解析参数 ────────────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in @@ -155,6 +192,14 @@ case "$OS" in *) die "Unsupported OS: $OS. Use install.ps1 or install.bat on Windows." ;; esac +# Test hook: run the same pre-upgrade backup the upgrade path uses, then stop +# before uv/pip. Production upgrades call _backup_before_upgrade again below, +# immediately before the package is replaced. +if [ "${FREEOS_INSTALL_BACKUP_ONLY:-}" = "1" ]; then + _backup_before_upgrade + exit 0 +fi + printf "${GREEN}[freeos]${RESET} Installing FreeOS into ${BOLD}%s${RESET}\n" "$OCTOP_HOME" # ── 步骤 1: 确保 uv 可用 ──────────────────────────────────────────────────── @@ -687,42 +732,6 @@ _clone_source_workspace() { fi } -_sha256_file() { - local file="$1" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$file" > "${file}.sha256" - elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" > "${file}.sha256" - else - return 1 - fi -} - -# Existing installs get a full archive before the package is replaced. -# A failed backup aborts the upgrade; the recorded sha256 is the checksum of that archive. -_backup_before_upgrade() { - local cli="$OCTOP_VENV/bin/octop" - if [ ! -x "$cli" ]; then - return 0 - fi - if [ ! -e "$OCTOP_HOME/octop.db" ] && [ ! -e "$OCTOP_HOME/config.json" ]; then - info "No existing database or config; skipping pre-upgrade backup" - return 0 - fi - mkdir -p "$OCTOP_HOME/backups" || die "Upgrade aborted: cannot create $OCTOP_HOME/backups" - local stamp out - stamp="$(date -u +%Y%m%dT%H%M%SZ)" - out="$OCTOP_HOME/backups/pre-upgrade-${stamp}.tar.gz" - info "Full backup before upgrade: $out" - if ! "$cli" backup create --home "$OCTOP_HOME" --include-chats -o "$out"; then - die "Upgrade aborted: backup failed" - fi - if ! _sha256_file "$out"; then - die "Upgrade aborted: could not record sha256 for $out" - fi - info "Backup sha256 written to ${out}.sha256" -} - _backup_before_upgrade if [ "$FROM_SOURCE" = true ]; then diff --git a/tests/unit/test_upgrade_backup.py b/tests/unit/test_upgrade_backup.py index 7902b6a5..c0c7083a 100644 --- a/tests/unit/test_upgrade_backup.py +++ b/tests/unit/test_upgrade_backup.py @@ -29,6 +29,97 @@ def test_install_ps1_backs_up_before_venv_clear() -> None: assert "SHA256" in text +def test_install_sh_backup_only_writes_archive_and_sha256(tmp_path: Path) -> None: + home = tmp_path / "home" + bin_dir = home / "venv" / "bin" + bin_dir.mkdir(parents=True) + (home / "octop.db").write_bytes(b"sqlite") + octop = bin_dir / "octop" + octop.write_text( + "#!/bin/sh\n" + "out=\n" + "prev=\n" + "for arg in \"$@\"; do\n" + " if [ \"$prev\" = \"-o\" ]; then out=$arg; fi\n" + " prev=$arg\n" + "done\n" + "mkdir -p \"$(dirname \"$out\")\"\n" + "printf backup > \"$out\"\n", + encoding="utf-8", + ) + octop.chmod(0o755) + python = bin_dir / "python" + python.write_text("#!/bin/sh\necho Python 3.12.12\n", encoding="utf-8") + python.chmod(0o755) + env = { + "HOME": str(home), + "FREEOS_HOME": str(home), + "PATH": "/usr/bin:/bin", + "FREEOS_INSTALL_BACKUP_ONLY": "1", + } + import os + import subprocess + + result = subprocess.run( + ["bash", str(REPO / "scripts" / "install.sh")], + cwd=REPO, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + archives = list((home / "backups").glob("pre-upgrade-*.tar.gz")) + assert len(archives) == 1 + checksum = Path(str(archives[0]) + ".sha256").read_text(encoding="utf-8") + assert archives[0].name in checksum + assert os.path.getsize(archives[0]) > 0 + + +def test_install_sh_backup_failure_aborts(tmp_path: Path) -> None: + home = tmp_path / "home" + bin_dir = home / "venv" / "bin" + bin_dir.mkdir(parents=True) + (home / "config.json").write_text("{}\n", encoding="utf-8") + octop = bin_dir / "octop" + octop.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + octop.chmod(0o755) + python = bin_dir / "python" + python.write_text("#!/bin/sh\necho Python 3.12.12\n", encoding="utf-8") + python.chmod(0o755) + import subprocess + + result = subprocess.run( + ["bash", str(REPO / "scripts" / "install.sh")], + cwd=REPO, + env={ + "HOME": str(home), + "FREEOS_HOME": str(home), + "PATH": "/usr/bin:/bin", + "FREEOS_INSTALL_BACKUP_ONLY": "1", + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 + assert "Upgrade aborted: backup failed" in result.stderr + assert list((home / "backups").glob("pre-upgrade-*.tar.gz.sha256")) == [] + + +def test_nsis_overwrite_backs_up_before_copying_files() -> None: + nsi = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "project.nsi").read_text(encoding="utf-8") + tools = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "wails_tools.nsh").read_text(encoding="utf-8") + backup = nsi.index("wails.backupBeforeOverwrite") + files = nsi.index("wails.files", backup) + assert backup < files + assert "Abort" in tools[tools.index("wails.backupBeforeOverwrite"):] + assert "backup-before-overwrite.ps1" in tools + script = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "backup-before-overwrite.ps1").read_text(encoding="utf-8") + assert "--include-chats" in script + assert "Upgrade aborted: backup failed" in script + + def test_in_app_upgrade_runs_full_backup_not_only_sqlite() -> None: text = (REPO / "desktop" / "src" / "portable_upgrade.go").read_text(encoding="utf-8") assert '"backup", "create"' in text From af078e987d618cb842320cf7fd0f7181c45bc2f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:15:54 +0000 Subject: [PATCH 30/39] F2 bootstrap tests: empty database and upgrade residue stay unchanged A release process with inherited local-test flags creates no demo accounts on an empty database, and three later starts leave an existing password hash and tenant status alone. Co-authored-by: haoboy007-sudo --- modules/openxyos/package.json | 3 +- modules/openxyos/scripts/test-f2-bootstrap.ts | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 modules/openxyos/scripts/test-f2-bootstrap.ts diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index 0722e489..da7bd4d2 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -11,9 +11,10 @@ "postinstall": "node scripts/patch-express-types.cjs", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", - "test": "npm run test:db-persistence && npm run test:no-release-bootstrap && npm run test:f10-upgrade-cleanup && npm run test:plugin-install && 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:doc-parser && 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:no-release-bootstrap && npm run test:f10-upgrade-cleanup && npm run test:plugin-install && npm run test:f2-bootstrap && 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:doc-parser && 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:f10-upgrade-cleanup": "tsx scripts/test-f10-upgrade-cleanup.ts", "test:plugin-install": "tsx scripts/test-plugin-install.ts", + "test:f2-bootstrap": "tsx scripts/test-f2-bootstrap.ts", "test:no-release-bootstrap": "tsx scripts/test-no-release-bootstrap.ts", "test:db-persistence": "tsx scripts/test-db-persistence.ts", "test:runtime-config": "tsx scripts/test-runtime-config.ts", diff --git a/modules/openxyos/scripts/test-f2-bootstrap.ts b/modules/openxyos/scripts/test-f2-bootstrap.ts new file mode 100644 index 00000000..d51ef367 --- /dev/null +++ b/modules/openxyos/scripts/test-f2-bootstrap.ts @@ -0,0 +1,50 @@ +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(), "freeos-f2-")); + process.env.DATABASE_PATH = path.join(dir, "xiongyuan.db"); + process.env.FREEOS_ORG_INTEGRATED = "1"; + process.env.FREEOS_ORG_LOCAL_TEST = "1"; + process.env.FREEOS_ORG_DEV_BOOTSTRAP = "1"; + delete process.env.FREEOS_DEV_BUILD; + delete process.env.SEED_DEMO_DATA; + + const { initDatabase, dbRun, dbGet } = await import("../backend/db"); + const { seedDatabase } = await import("../backend/seed"); + await initDatabase(); + await seedDatabase(); + assert.equal(dbGet("SELECT id FROM users WHERE email = ?", ["demo@demo.com"]), undefined); + assert.equal(dbGet("SELECT id FROM users WHERE email = ?", ["user@demo.com"]), undefined); + assert.equal(dbGet("SELECT id FROM users WHERE email = ?", ["admin@example.com"]), undefined); + assert.equal(fs.existsSync(path.join(dir, "bootstrap-credentials.txt")), false); + + dbRun("INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (2, 'local', 'openxyos-local', 'suspended', 'community')"); + dbRun("UPDATE tenants SET status = 'suspended' WHERE id = 2"); + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'admin', 2, 0)", + ["demo@demo.com", "hash-keep-demo", "管理员"], + ); + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'admin', 1, 0)", + ["owner@example.com", "hash-keep-owner", "Owner"], + ); + const before = { + demo: dbGet("SELECT password_hash, must_change_password, status FROM users WHERE email = ?", ["demo@demo.com"]), + owner: dbGet("SELECT password_hash, must_change_password FROM users WHERE email = ?", ["owner@example.com"]), + tenant: dbGet("SELECT status FROM tenants WHERE id = 2"), + }; + for (let i = 0; i < 3; i += 1) await seedDatabase(); + assert.deepEqual(dbGet("SELECT password_hash, must_change_password, status FROM users WHERE email = ?", ["demo@demo.com"]), before.demo); + assert.deepEqual(dbGet("SELECT password_hash, must_change_password FROM users WHERE email = ?", ["owner@example.com"]), before.owner); + assert.deepEqual(dbGet("SELECT status FROM tenants WHERE id = 2"), before.tenant); + assert.equal(fs.existsSync(path.join(dir, "bootstrap-credentials.txt")), false); + console.log("f2 empty db and upgrade residue ok"); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From c28cabe325d64f80ac3ba570c06affbe566e8e99 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:16:41 +0000 Subject: [PATCH 31/39] F1 launch flags: ignore inherited demo-seed switches in release builds FREEOS_ORG_LOCAL_TEST and FREEOS_ORG_DEV_BOOTSTRAP are dropped unless FREEOS_DEV_BUILD is explicitly set. Parent environments can no longer turn demo seeding on for a release sidecar. Co-authored-by: haoboy007-sudo --- desktop/portable/bundle-org-sidecar.sh | 27 ++++++++++++------- .../src/build/windows/nsis/start-sidecar.ps1 | 7 ++--- desktop/src/process_sidecar.go | 17 ++++++++++-- desktop/src/process_sidecar_test.go | 14 ++++++++++ src/octop/modules/org_os/sidecar_launch.py | 13 +++++---- tests/unit/test_sidecar_launch.py | 12 +++++++++ 6 files changed, 71 insertions(+), 19 deletions(-) diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index 2db54011..4e18b262 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -228,16 +228,22 @@ export AIR_GAP_MODE=true export ALLOW_PUBLIC_REGISTRATION="${ALLOW_PUBLIC_REGISTRATION:-false}" export SEED_DEMO_DATA="${SEED_DEMO_DATA:-false}" export FREEOS_ORG_INTEGRATED="${FREEOS_ORG_INTEGRATED:-1}" +# Release builds ignore inherited demo-seed switches unless FREEOS_DEV_BUILD is set. org_local_test=0 -for value in "${FREEOS_ORG_DEV_BOOTSTRAP:-}" "${FREEOS_ORG_LOCAL_TEST:-}"; do - case "$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" in - 1|true|yes|on) org_local_test=1 ;; - esac -done +case "$(printf '%s' "${FREEOS_DEV_BUILD:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) + for value in "${FREEOS_ORG_DEV_BOOTSTRAP:-}" "${FREEOS_ORG_LOCAL_TEST:-}"; do + case "$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) org_local_test=1 ;; + esac + done + ;; +esac if [ "$org_local_test" = 1 ]; then export FREEOS_ORG_LOCAL_TEST=1 else unset FREEOS_ORG_LOCAL_TEST || true + unset FREEOS_ORG_DEV_BOOTSTRAP || true fi export JWT_SECRET COOKIE_SECRET export CORS_ORIGIN="${CORS_ORIGIN:-http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900}" @@ -281,14 +287,17 @@ if not defined ALLOW_PUBLIC_REGISTRATION set "ALLOW_PUBLIC_REGISTRATION=false" if not defined SEED_DEMO_DATA set "SEED_DEMO_DATA=false" if not defined FREEOS_ORG_INTEGRATED set "FREEOS_ORG_INTEGRATED=1" set "ORG_LOCAL_TEST=" -if /I "%FREEOS_ORG_DEV_BOOTSTRAP%"=="1" set "ORG_LOCAL_TEST=1" -if /I "%FREEOS_ORG_DEV_BOOTSTRAP%"=="true" set "ORG_LOCAL_TEST=1" -if /I "%FREEOS_ORG_LOCAL_TEST%"=="1" set "ORG_LOCAL_TEST=1" -if /I "%FREEOS_ORG_LOCAL_TEST%"=="true" set "ORG_LOCAL_TEST=1" +if /I "%FREEOS_DEV_BUILD%"=="1" set "ORG_DEV_OK=1" +if /I "%FREEOS_DEV_BUILD%"=="true" set "ORG_DEV_OK=1" +if defined ORG_DEV_OK if /I "%FREEOS_ORG_DEV_BOOTSTRAP%"=="1" set "ORG_LOCAL_TEST=1" +if defined ORG_DEV_OK if /I "%FREEOS_ORG_DEV_BOOTSTRAP%"=="true" set "ORG_LOCAL_TEST=1" +if defined ORG_DEV_OK if /I "%FREEOS_ORG_LOCAL_TEST%"=="1" set "ORG_LOCAL_TEST=1" +if defined ORG_DEV_OK if /I "%FREEOS_ORG_LOCAL_TEST%"=="true" set "ORG_LOCAL_TEST=1" if defined ORG_LOCAL_TEST ( set "FREEOS_ORG_LOCAL_TEST=1" ) else ( set "FREEOS_ORG_LOCAL_TEST=" + set "FREEOS_ORG_DEV_BOOTSTRAP=" ) if not defined CORS_ORIGIN set "CORS_ORIGIN=http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900" cd /d "%APP%" diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 6c971d7b..9a962c97 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -252,12 +252,13 @@ $env:FREEOS_HOME = $freeosHome $env:OCTOP_HOME = $freeosHome $env:FREEOS_ORG_SIDECAR_PORT = '3780' $env:FREEOS_ORG_INTEGRATED = '1' -# Demo accounts (demo@demo.com) only when an operator opts in. Release launches -# leave FREEOS_ORG_LOCAL_TEST unset so the sidecar does not seed or rotate them. -if ($env:FREEOS_ORG_DEV_BOOTSTRAP -match '^(1|true|yes|on)$') { +# Release builds ignore inherited demo-seed switches. They are honoured only +# when FREEOS_DEV_BUILD is explicitly set. +if ($env:FREEOS_DEV_BUILD -match '^(1|true|yes|on)$' -and ($env:FREEOS_ORG_DEV_BOOTSTRAP -match '^(1|true|yes|on)$' -or $env:FREEOS_ORG_LOCAL_TEST -match '^(1|true|yes|on)$')) { $env:FREEOS_ORG_LOCAL_TEST = '1' } else { Remove-Item Env:\FREEOS_ORG_LOCAL_TEST -ErrorAction SilentlyContinue + Remove-Item Env:\FREEOS_ORG_DEV_BOOTSTRAP -ErrorAction SilentlyContinue } $compiled = Join-Path $app 'backend-dist\server.js' if (Test-Path -LiteralPath $compiled) { diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index b0f4ec5c..4cedeaa2 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -318,8 +318,8 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) "OCTOP_HOME": home, "FREEOS_ORG_SIDECAR_PORT": strconv.Itoa(defaultSidecarPort), // Integrated mode is the real FreeOS organization bridge (distinct - // tenants, host identity). Demo accounts stay off unless a dev switch - // is already set in the parent environment. + // tenants, host identity). Release builds ignore inherited demo-seed + // switches; only FREEOS_DEV_BUILD honours them. "FREEOS_ORG_INTEGRATED": "1", } if orgLocalTestRequested() { @@ -328,7 +328,20 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) return env, nil } +func envTruthy(key string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func orgLocalTestRequested() bool { + // Release builds hard-ignore a parent FREEOS_ORG_LOCAL_TEST. + if !envTruthy("FREEOS_DEV_BUILD") { + return false + } for _, key := range []string{"FREEOS_ORG_DEV_BOOTSTRAP", "FREEOS_ORG_LOCAL_TEST"} { switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { case "1", "true", "yes", "on": diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index b290ee8e..95ddc633 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -67,6 +67,7 @@ func TestSidecarLaunchEnvPointsAtHomeDatabase(t *testing.T) { } func TestSidecarLaunchEnvLocalTestRequiresDevSwitch(t *testing.T) { + t.Setenv("FREEOS_DEV_BUILD", "1") t.Setenv("FREEOS_ORG_DEV_BOOTSTRAP", "1") t.Setenv("FREEOS_ORG_LOCAL_TEST", "") env, err := sidecarLaunchEnv(t.TempDir(), 8088) @@ -78,6 +79,19 @@ func TestSidecarLaunchEnvLocalTestRequiresDevSwitch(t *testing.T) { } } +func TestSidecarLaunchEnvIgnoresInheritedLocalTest(t *testing.T) { + t.Setenv("FREEOS_DEV_BUILD", "") + t.Setenv("FREEOS_ORG_DEV_BOOTSTRAP", "1") + t.Setenv("FREEOS_ORG_LOCAL_TEST", "1") + env, err := sidecarLaunchEnv(t.TempDir(), 8088) + if err != nil { + t.Fatal(err) + } + if value, ok := env["FREEOS_ORG_LOCAL_TEST"]; ok && value != "" { + t.Fatalf("release launch kept FREEOS_ORG_LOCAL_TEST=%q", value) + } +} + func TestSidecarReadyRequiresFrontendBuild(t *testing.T) { root := t.TempDir() t.Setenv("FREEOS_HOME", root) diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 9dafa0bd..2c6a59ae 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -359,13 +359,16 @@ def apply_org_launch_flags(env: dict[str, str]) -> None: """Mark the sidecar as the FreeOS organization module, without demo users. ``FREEOS_ORG_INTEGRATED`` is required for a real install: registration - creates a distinct tenant instead of the shared demo tenant, and the host - accepts organization identity. ``FREEOS_ORG_LOCAL_TEST`` seeds - ``demo@demo.com`` and is set only when ``FREEOS_ORG_DEV_BOOTSTRAP`` or - ``FREEOS_ORG_LOCAL_TEST`` is already explicitly on. Release launches leave - it unset. + creates a distinct tenant instead of a shared tenant, and the host accepts + organization identity. Release builds hard-ignore inherited + ``FREEOS_ORG_LOCAL_TEST`` and ``FREEOS_ORG_DEV_BOOTSTRAP``. Those switches + are honoured only when ``FREEOS_DEV_BUILD`` is explicitly set. """ env["FREEOS_ORG_INTEGRATED"] = "1" + if not _env_truthy(env, "FREEOS_DEV_BUILD"): + env.pop("FREEOS_ORG_LOCAL_TEST", None) + env.pop("FREEOS_ORG_DEV_BOOTSTRAP", None) + return if _env_truthy(env, "FREEOS_ORG_DEV_BOOTSTRAP") or _env_truthy(env, "FREEOS_ORG_LOCAL_TEST"): env["FREEOS_ORG_LOCAL_TEST"] = "1" return diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py index da533ea7..20c359d0 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -313,11 +313,23 @@ def test_sidecar_launch_env_local_test_requires_explicit_switch( ) -> None: monkeypatch.delenv("FREEOS_ORG_LOCAL_TEST", raising=False) monkeypatch.setenv("FREEOS_ORG_DEV_BOOTSTRAP", "1") + monkeypatch.setenv("FREEOS_DEV_BUILD", "1") env = sidecar_launch_env(tmp_path, dashboard_port=8099) assert env["FREEOS_ORG_INTEGRATED"] == "1" assert env["FREEOS_ORG_LOCAL_TEST"] == "1" +def test_sidecar_launch_env_ignores_inherited_local_test( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FREEOS_ORG_LOCAL_TEST", "1") + monkeypatch.setenv("FREEOS_ORG_DEV_BOOTSTRAP", "true") + monkeypatch.delenv("FREEOS_DEV_BUILD", raising=False) + env = sidecar_launch_env(tmp_path, dashboard_port=8099) + assert "FREEOS_ORG_LOCAL_TEST" not in env + assert "FREEOS_ORG_DEV_BOOTSTRAP" not in env + + def test_sidecar_launch_env_merges_existing_cors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From afde946b1e3b8ac6621e32c7652b495ae53b3691 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:18:09 +0000 Subject: [PATCH 32/39] H bind loopback: default organization and Docker listeners to 127.0.0.1 Inherited HOST is ignored. LAN or NAS exposure requires FREEOS_ORG_BIND_HOST or OCTOP_BIND_HOST. Compose keeps a container-internal 0.0.0.0 only where the published port defaults to 127.0.0.1; host-network mobile stays on loopback. Co-authored-by: haoboy007-sudo --- desktop/portable/bundle-org-sidecar.sh | 4 ++++ .../src/build/windows/nsis/start-sidecar.ps1 | 2 ++ desktop/src/process_sidecar.go | 7 +++++- docker/docker-compose.mobile.yml | 4 +++- docker/docker-compose.yml | 5 +++- docker/docker-entrypoint.sh | 8 +++++-- modules/openxyos/backend/listen-host.ts | 16 +++++++++++++ modules/openxyos/backend/server.ts | 3 ++- modules/openxyos/deploy/docker-compose.yml | 6 ++++- modules/openxyos/package.json | 3 ++- modules/openxyos/scripts/test-listen-host.ts | 8 +++++++ modules/openxyos/vite.config.ts | 3 ++- src/octop/modules/org_os/sidecar_launch.py | 4 +++- tests/unit/test_bind_loopback.py | 23 +++++++++++++++++++ tests/unit/test_sidecar_launch.py | 12 ++++++++++ 15 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 modules/openxyos/backend/listen-host.ts create mode 100644 modules/openxyos/scripts/test-listen-host.ts create mode 100644 tests/unit/test_bind_loopback.py diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index 4e18b262..23dff8d6 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -222,6 +222,9 @@ fi export NODE_ENV=production export PORT export HOST=127.0.0.1 +if [ -n "${FREEOS_ORG_BIND_HOST:-}" ]; then + export HOST="${FREEOS_ORG_BIND_HOST}" +fi export DB_DIALECT=sqlite export DATABASE_PATH="${DATA_DIR}/xiongyuan.db" export AIR_GAP_MODE=true @@ -280,6 +283,7 @@ if not defined COOKIE_SECRET ( set "NODE_ENV=production" set "PORT=%FREEOS_ORG_SIDECAR_PORT%" set "HOST=127.0.0.1" +if defined FREEOS_ORG_BIND_HOST set "HOST=%FREEOS_ORG_BIND_HOST%" set "DB_DIALECT=sqlite" set "DATABASE_PATH=%FREEOS_ORG_DATA%\xiongyuan.db" set "AIR_GAP_MODE=true" diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 9a962c97..3f4304d5 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -236,6 +236,8 @@ if (-not $ingest) { $ingest = [guid]::NewGuid().ToString('N') + [guid]::NewGuid( $env:NODE_ENV = 'production' $env:PORT = '3780' $env:HOST = '127.0.0.1' +# Inherited HOST is ignored. FREEOS_ORG_BIND_HOST is the LAN/NAS opt-in. +if ($env:FREEOS_ORG_BIND_HOST) { $env:HOST = $env:FREEOS_ORG_BIND_HOST } $env:DB_DIALECT = 'sqlite' $env:DATABASE_PATH = Join-Path $data 'xiongyuan.db' $env:AIR_GAP_MODE = 'true' diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index 4cedeaa2..e30c0139 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -301,10 +301,15 @@ func sidecarLaunchEnv(home string, dashboardPort int) (map[string]string, error) "http://127.0.0.1:%d,http://localhost:%d,http://127.0.0.1:18900,http://localhost:18900,http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:%d,http://localhost:%d,http://[::1]:%d", dashboardPort, dashboardPort, defaultSidecarPort, defaultSidecarPort, defaultSidecarPort, ) + bindHost := "127.0.0.1" + if explicit := strings.TrimSpace(os.Getenv("FREEOS_ORG_BIND_HOST")); explicit != "" { + // Explicit opt-in for LAN/NAS. Inherited HOST is ignored. + bindHost = explicit + } env := map[string]string{ "NODE_ENV": "production", "PORT": strconv.Itoa(defaultSidecarPort), - "HOST": "127.0.0.1", + "HOST": bindHost, "DB_DIALECT": "sqlite", "DATABASE_PATH": filepath.Join(sidecarDataDir(home), "xiongyuan.db"), "AIR_GAP_MODE": "true", diff --git a/docker/docker-compose.mobile.yml b/docker/docker-compose.mobile.yml index 7c38a0ba..a74972eb 100644 --- a/docker/docker-compose.mobile.yml +++ b/docker/docker-compose.mobile.yml @@ -35,7 +35,9 @@ services: - ${OCTOP_PLATFORM_TOOLS:-../.tools/platform-tools}:/opt/android-sdk/platform-tools:ro environment: - HOME=/data - - OCTOP_BIND_HOST=0.0.0.0 + # network_mode: host has no port mapping, so this bind is the host bind. + # Stay on loopback unless the operator sets OCTOP_BIND_HOST=0.0.0.0. + - OCTOP_BIND_HOST=${OCTOP_BIND_HOST:-127.0.0.1} - OCTOP_PORT=${OCTOP_PORT:-8088} - OCTOP_LOG_LEVEL=${OCTOP_LOG_LEVEL:-info} - OCTOP_DEFAULT_PASSWORD=${OCTOP_DEFAULT_PASSWORD:-} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 26236f8a..2dd475ab 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -34,7 +34,10 @@ services: container_name: octop restart: unless-stopped ports: - - "${OCTOP_PORT:-8088}:${OCTOP_PORT:-8088}" + # Host side is loopback. Set OCTOP_PUBLISH_HOST=0.0.0.0 to opt into LAN. + # OCTOP_BIND_HOST=0.0.0.0 below is container-internal so the published + # port can reach the process; it does not open the host LAN by itself. + - "${OCTOP_PUBLISH_HOST:-127.0.0.1}:${OCTOP_PORT:-8088}:${OCTOP_PORT:-8088}" volumes: - ${OCTOP_DATA:-~/.freeos}:/data/.freeos environment: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 6b10405d..416311f7 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -103,8 +103,12 @@ EOF fi if [ $# -eq 0 ]; then - echo "[entrypoint] 正在启动 FreeOS,端口 $PORT..." - exec octop run --host 0.0.0.0 --port "$PORT" + # Loopback unless OCTOP_BIND_HOST is set. Compose sets 0.0.0.0 inside the + # container only together with a 127.0.0.1 published port. A bare docker + # run stays on loopback. Host-network mode must not default to 0.0.0.0. + BIND_HOST="${OCTOP_BIND_HOST:-127.0.0.1}" + echo "[entrypoint] 正在启动 FreeOS,端口 $PORT,绑定 $BIND_HOST..." + exec octop run --host "$BIND_HOST" --port "$PORT" fi if [ "$1" = "octop" ]; then diff --git a/modules/openxyos/backend/listen-host.ts b/modules/openxyos/backend/listen-host.ts new file mode 100644 index 00000000..65e07be4 --- /dev/null +++ b/modules/openxyos/backend/listen-host.ts @@ -0,0 +1,16 @@ +/** + * Listen address for the organization process. + * + * The default is loopback. Inherited HOST is ignored so a parent 0.0.0.0 + * cannot expose the process. LAN, NAS, and Docker exposure require the + * explicit FREEOS_ORG_BIND_HOST opt-in. + * + * Docker compose publishes 127.0.0.1:host:container and may set + * FREEOS_ORG_BIND_HOST=0.0.0.0 so the process is reachable from the container + * proxy without opening the host LAN. Host-network compose has no published + * port, so the process itself stays on 127.0.0.1 unless the operator opts in. + */ +export function resolveListenHost(env: NodeJS.ProcessEnv = process.env): string { + const explicit = (env.FREEOS_ORG_BIND_HOST || "").trim(); + return explicit || "127.0.0.1"; +} diff --git a/modules/openxyos/backend/server.ts b/modules/openxyos/backend/server.ts index 73e8f75c..10ee9ec4 100644 --- a/modules/openxyos/backend/server.ts +++ b/modules/openxyos/backend/server.ts @@ -8,6 +8,7 @@ import path from "path"; import fs from "fs"; import { createServer } from "http"; import { initDatabase } from "./db"; +import { resolveListenHost } from "./listen-host"; import { authRoutes } from "./routes/auth"; import { employeeRoutes } from "./routes/employees"; import { taskRoutes } from "./routes/tasks"; @@ -282,7 +283,7 @@ app.get("/admin/database", (req, res) => { setupWebSocket(server); - server.listen(PORT, process.env.HOST || '127.0.0.1', () => { + server.listen(PORT, resolveListenHost(), () => { console.log(`\n 🌐 openXYOS Community — Human + Agent OS`); console.log(` ─────────────────────────────────────`); console.log(` Server: http://localhost:${PORT}`); diff --git a/modules/openxyos/deploy/docker-compose.yml b/modules/openxyos/deploy/docker-compose.yml index 053e827f..b85a1247 100644 --- a/modules/openxyos/deploy/docker-compose.yml +++ b/modules/openxyos/deploy/docker-compose.yml @@ -13,8 +13,12 @@ services: DEPLOY_MODE: community PORT: "3000" SEED_DEMO_DATA: "false" + # Container-internal only. The published port below is loopback, so this + # does not expose the process on the host LAN. Set the host mapping to + # 0.0.0.0:3000:3000 only as an explicit opt-in. + FREEOS_ORG_BIND_HOST: "0.0.0.0" ports: - - "3000:3000" + - "127.0.0.1:3000:3000" volumes: - xyos_data:/app/backend/data - xyos_uploads:/app/uploads diff --git a/modules/openxyos/package.json b/modules/openxyos/package.json index da7bd4d2..7a43451b 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -11,10 +11,11 @@ "postinstall": "node scripts/patch-express-types.cjs", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", - "test": "npm run test:db-persistence && npm run test:no-release-bootstrap && npm run test:f10-upgrade-cleanup && npm run test:plugin-install && npm run test:f2-bootstrap && 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:doc-parser && 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:no-release-bootstrap && npm run test:f10-upgrade-cleanup && npm run test:plugin-install && npm run test:f2-bootstrap && npm run test:listen-host && 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:doc-parser && 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:f10-upgrade-cleanup": "tsx scripts/test-f10-upgrade-cleanup.ts", "test:plugin-install": "tsx scripts/test-plugin-install.ts", "test:f2-bootstrap": "tsx scripts/test-f2-bootstrap.ts", + "test:listen-host": "tsx scripts/test-listen-host.ts", "test:no-release-bootstrap": "tsx scripts/test-no-release-bootstrap.ts", "test:db-persistence": "tsx scripts/test-db-persistence.ts", "test:runtime-config": "tsx scripts/test-runtime-config.ts", diff --git a/modules/openxyos/scripts/test-listen-host.ts b/modules/openxyos/scripts/test-listen-host.ts new file mode 100644 index 00000000..babbf82f --- /dev/null +++ b/modules/openxyos/scripts/test-listen-host.ts @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import { resolveListenHost } from "../backend/listen-host"; + +assert.equal(resolveListenHost({}), "127.0.0.1"); +assert.equal(resolveListenHost({ HOST: "0.0.0.0" }), "127.0.0.1"); +assert.equal(resolveListenHost({ FREEOS_ORG_BIND_HOST: "0.0.0.0" }), "0.0.0.0"); +assert.equal(resolveListenHost({ FREEOS_ORG_BIND_HOST: " " }), "127.0.0.1"); +console.log("listen host ok"); diff --git a/modules/openxyos/vite.config.ts b/modules/openxyos/vite.config.ts index fb658bd0..959f5a00 100644 --- a/modules/openxyos/vite.config.ts +++ b/modules/openxyos/vite.config.ts @@ -44,7 +44,8 @@ export default defineConfig(({ mode }) => { alias: { "@": path.resolve(__dirname, "./frontend/src") }, }, server: { - host: "0.0.0.0", + // Dev server stays on loopback. Set FREEOS_ORG_BIND_HOST to opt into LAN. + host: process.env.FREEOS_ORG_BIND_HOST || "127.0.0.1", port: 5174, proxy: { "/api": "http://localhost:3000", diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 2c6a59ae..a7c48dda 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -397,11 +397,13 @@ def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict if configured else origin ) + # Inherited HOST is not an opt-in. FREEOS_ORG_BIND_HOST is. + bind_host = (env.get("FREEOS_ORG_BIND_HOST") or "127.0.0.1").strip() or "127.0.0.1" env.update( { "NODE_ENV": "production", "PORT": port, - "HOST": "127.0.0.1", + "HOST": bind_host, "DB_DIALECT": "sqlite", "DATABASE_PATH": str(sidecar_data_dir(home) / "xiongyuan.db"), "AIR_GAP_MODE": "true", diff --git a/tests/unit/test_bind_loopback.py b/tests/unit/test_bind_loopback.py new file mode 100644 index 00000000..bdb9ea3f --- /dev/null +++ b/tests/unit/test_bind_loopback.py @@ -0,0 +1,23 @@ +"""Organization and Docker binds stay on loopback unless explicitly opted in.""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + + +def test_compose_publishes_loopback_and_documents_container_bind() -> None: + compose = (REPO / "docker" / "docker-compose.yml").read_text(encoding="utf-8") + assert "127.0.0.1}:${OCTOP_PORT:-8088}:${OCTOP_PORT:-8088}" in compose + assert "OCTOP_BIND_HOST=0.0.0.0" in compose + assert "container-internal" in compose + mobile = (REPO / "docker" / "docker-compose.mobile.yml").read_text(encoding="utf-8") + assert "OCTOP_BIND_HOST=${OCTOP_BIND_HOST:-127.0.0.1}" in mobile + assert "network_mode: host" in mobile + entry = (REPO / "docker" / "docker-entrypoint.sh").read_text(encoding="utf-8") + assert 'BIND_HOST="${OCTOP_BIND_HOST:-127.0.0.1}"' in entry + assert "--host 0.0.0.0" not in entry + org = (REPO / "modules" / "openxyos" / "deploy" / "docker-compose.yml").read_text(encoding="utf-8") + assert "127.0.0.1:3000:3000" in org + assert 'FREEOS_ORG_BIND_HOST: "0.0.0.0"' in org diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py index 20c359d0..0e28ed24 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -319,6 +319,18 @@ def test_sidecar_launch_env_local_test_requires_explicit_switch( assert env["FREEOS_ORG_LOCAL_TEST"] == "1" +def test_sidecar_launch_env_ignores_inherited_bind_all( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOST", "0.0.0.0") + monkeypatch.delenv("FREEOS_ORG_BIND_HOST", raising=False) + env = sidecar_launch_env(tmp_path, dashboard_port=8099) + assert env["HOST"] == "127.0.0.1" + monkeypatch.setenv("FREEOS_ORG_BIND_HOST", "0.0.0.0") + exposed = sidecar_launch_env(tmp_path, dashboard_port=8099) + assert exposed["HOST"] == "0.0.0.0" + + def test_sidecar_launch_env_ignores_inherited_local_test( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 8227926395e9f6b8b59d18d0112ccd6f41a3eade Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:18:09 +0000 Subject: [PATCH 33/39] I ui tenant: never prefer demo or seed tenants resolveUiTenantId follows the logged-in non-seed user. A later demo login and the openxyos-local slug no longer win over that user's own tenant. Co-authored-by: haoboy007-sudo --- .../openxyos/backend/routes/freeos-bridge.ts | 43 +++++++++++-------- .../openxyos/scripts/test-freeos-ingest.ts | 32 +++++++++----- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/modules/openxyos/backend/routes/freeos-bridge.ts b/modules/openxyos/backend/routes/freeos-bridge.ts index f468eeaf..16c52c86 100644 --- a/modules/openxyos/backend/routes/freeos-bridge.ts +++ b/modules/openxyos/backend/routes/freeos-bridge.ts @@ -50,38 +50,43 @@ function tenantExists(id: number): boolean { return Boolean(dbGet("SELECT id FROM tenants WHERE id = ?", [id])); } -/** Tenant the embedded / local login actually uses — never assume id=1. */ +const SEED_EMAILS = "('demo@demo.com', 'user@demo.com')"; +const SEED_SLUGS = "('openxyos-demo', 'openxyos-local')"; + +/** Tenant the signed-in operator owns. Demo and seed tenants are never preferred. */ export function resolveUiTenantId(): number { const recent = dbGet( - `SELECT tenant_id FROM users - WHERE last_login IS NOT NULL - ORDER BY datetime(last_login) DESC, id DESC + `SELECT u.tenant_id AS tenant_id + FROM users u + LEFT JOIN tenants t ON t.id = u.tenant_id + WHERE u.last_login IS NOT NULL + AND lower(u.email) NOT IN ${SEED_EMAILS} + AND COALESCE(t.slug, '') NOT IN ${SEED_SLUGS} + ORDER BY datetime(u.last_login) DESC, u.id DESC LIMIT 1`, ) as { tenant_id?: number } | undefined; const recentId = Number(recent?.tenant_id); if (tenantExists(recentId)) return recentId; - const demo = dbGet( - `SELECT tenant_id FROM users - WHERE email IN ('demo@demo.com', 'user@demo.com') - ORDER BY CASE email WHEN 'demo@demo.com' THEN 0 ELSE 1 END + const owned = dbGet( + `SELECT u.tenant_id AS tenant_id + FROM users u + LEFT JOIN tenants t ON t.id = u.tenant_id + WHERE lower(u.email) NOT IN ${SEED_EMAILS} + AND COALESCE(t.slug, '') NOT IN ${SEED_SLUGS} + ORDER BY u.id ASC LIMIT 1`, ) as { tenant_id?: number } | undefined; - const demoId = Number(demo?.tenant_id); - if (tenantExists(demoId)) return demoId; + const ownedId = Number(owned?.tenant_id); + if (tenantExists(ownedId)) return ownedId; - const local = dbGet( + const first = dbGet( `SELECT id FROM tenants - WHERE slug IN ('openxyos-local', 'openxyos-demo') - AND status IN ('active', 'trial') - ORDER BY id DESC + WHERE COALESCE(slug, '') NOT IN ${SEED_SLUGS} + ORDER BY id LIMIT 1`, ) as { id?: number } | undefined; - const localId = Number(local?.id); - if (tenantExists(localId)) return localId; - - const first = dbGet("SELECT id FROM tenants ORDER BY id LIMIT 1") as { id?: number } | undefined; - return Number(first?.id) || 1; + return Number(first?.id) || 0; } export function resolveIngestTenantIds(requested: unknown): { primary: number; mirror: number[] } { diff --git a/modules/openxyos/scripts/test-freeos-ingest.ts b/modules/openxyos/scripts/test-freeos-ingest.ts index 91fd6973..d489174a 100644 --- a/modules/openxyos/scripts/test-freeos-ingest.ts +++ b/modules/openxyos/scripts/test-freeos-ingest.ts @@ -32,6 +32,9 @@ async function main() { "INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (2, ?, ?, 'active', 'community')", ["openXYOS 本地租户", "openxyos-local"], ); + dbRun( + "INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (3, 'Acme', 'acme', 'active', 'community')", + ); dbRun( "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'super_admin', 1)", ["admin@example.com", "unused", "超级管理员"], @@ -40,14 +43,19 @@ async function main() { "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'admin', 2)", ["demo@demo.com", "unused", "管理员"], ).lastInsertRowid); - dbRun("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?", [demoId]); + const ownerId = Number(dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id) VALUES (?, ?, ?, 'admin', 3)", + ["owner@example.com", "unused", "Owner"], + ).lastInsertRowid); + dbRun("UPDATE users SET last_login = '2020-01-01 00:00:00' WHERE id = ?", [ownerId]); + dbRun("UPDATE users SET last_login = '2099-01-01 00:00:00' WHERE id = ?", [demoId]); const token = signToken({ - id: demoId, - email: "demo@demo.com", - nickname: "管理员", + id: ownerId, + email: "owner@example.com", + nickname: "Owner", role: "admin", - tenant_id: 2, + tenant_id: 3, }); const app = express(); @@ -84,7 +92,7 @@ async function main() { const health = await fetch(`${baseUrl}/api/freeos/health`); assert.equal(health.status, 200); const healthBody = await health.json() as { data: { ui_tenant_id: number } }; - assert.equal(healthBody.data.ui_tenant_id, 2); + assert.equal(healthBody.data.ui_tenant_id, 3); const ingested = await ingest({ tenant_id: 1, @@ -118,7 +126,7 @@ async function main() { data: { tenant_id: number; also_tenants: number[]; landed: Record }; }; assert.equal(ingestBody.success, true); - assert.equal(ingestBody.data.tenant_id, 2); + assert.equal(ingestBody.data.tenant_id, 3); assert.deepEqual(ingestBody.data.also_tenants, [1]); assert.equal(ingestBody.data.landed.employees.created, 2); assert.equal(ingestBody.data.landed.talent.created, 2); @@ -131,7 +139,7 @@ async function main() { assert.deepEqual(names, ["Ops Coordinator", "Policy Analyst"]); assert(employeeBody.data.every((row) => row.employment_category === "internal")); assert(employeeBody.data.every((row) => row.status === "active")); - assert(employeeBody.data.every((row) => row.tenant_id === 2)); + assert(employeeBody.data.every((row) => row.tenant_id === 3)); const talent = await asDemo("/api/talent"); assert.equal(talent.status, 200); @@ -155,10 +163,14 @@ async function main() { "SELECT name FROM employees WHERE tenant_id = 1 ORDER BY name", ) as Array<{ name: string }>; assert.deepEqual(tenant1Employees.map((row) => row.name), ["Ops Coordinator", "Policy Analyst"]); - const tenant2Count = dbGet( + const tenant3Count = dbGet( + "SELECT COUNT(*) as c FROM employees WHERE tenant_id = 3", + ) as { c: number }; + assert.equal(Number(tenant3Count.c), 2); + const seedTenantCount = dbGet( "SELECT COUNT(*) as c FROM employees WHERE tenant_id = 2", ) as { c: number }; - assert.equal(Number(tenant2Count.c), 2); + assert.equal(Number(seedTenantCount.c), 0); console.log("freeos ingest writes to the active UI tenant and list APIs see the rows"); } finally { From d4701a5ff8389a50211fc82fc962b621c6c43a4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:19:20 +0000 Subject: [PATCH 34/39] J plugin price: hide paid plugins after cleanup until price is confirmed The catalog list no longer returns a price, and the plugin center shows an amount only after the install API asks for confirmation. A migrated contract plugin stays out of the active list; uninstall goes through the HTTP route and leaves the row disabled. Co-authored-by: haoboy007-sudo --- modules/openxyos/backend/routes/plugins.ts | 6 ++- .../frontend/src/pages/SkillsPage.tsx | 33 ++++++++++------ .../scripts/test-f10-upgrade-cleanup.ts | 38 +++++++++++++++++++ 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/modules/openxyos/backend/routes/plugins.ts b/modules/openxyos/backend/routes/plugins.ts index eb244689..aef366db 100644 --- a/modules/openxyos/backend/routes/plugins.ts +++ b/modules/openxyos/backend/routes/plugins.ts @@ -46,7 +46,11 @@ pluginRoutes.get("/", (req: AuthRequest, res) => { else if (sort === "newest") sql += " ORDER BY created_at DESC"; else sql += " ORDER BY category, name"; - res.json({ success: true, data: dbAll(sql, params) }); + const rows = (dbAll(sql, params) as Array>).map((row) => { + const { price, ...rest } = row; + return { ...rest, paid: isPaidPrice(price) }; + }); + res.json({ success: true, data: rows }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } diff --git a/modules/openxyos/frontend/src/pages/SkillsPage.tsx b/modules/openxyos/frontend/src/pages/SkillsPage.tsx index 2ca241af..19fcf3b8 100644 --- a/modules/openxyos/frontend/src/pages/SkillsPage.tsx +++ b/modules/openxyos/frontend/src/pages/SkillsPage.tsx @@ -537,26 +537,33 @@ function PluginCenter() { async function handleInstall(p: any) { if (installing.has(p.id)) return; - - // 付费插件 → 弹出付费窗口 - if (p.price !== "免费" && p.price !== "免费额度") { - setPayPlugin(p); + if (p.paid) { + const r = await authFetch(`/api/plugins/${p.id}/install`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const j = await r.json().catch(() => null); + if (r.status === 409 && j?.data?.price) { + setPayPlugin({ ...p, price: j.data.price }); + } return; } - - // 免费插件 → 直接安装 await doInstall(p.id); } - async function doInstall(pluginId: number) { + async function doInstall(pluginId: number, body?: Record) { setInstalling(prev => new Set(prev).add(pluginId)); try { - const r = await authFetch(`/api/plugins/${pluginId}/install`, { method: "POST" }); + const r = await authFetch(`/api/plugins/${pluginId}/install`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); if (r.ok) { const j = await r.json(); if (j.success) { setInstalledIds(prev => new Set(prev).add(pluginId)); - // 刷新安装数 load(); } } @@ -570,8 +577,10 @@ function PluginCenter() { function handlePayConfirm() { if (!payPlugin) return; - alert(t("商业插件需通过正式采购或授权流程开通,当前演示站不会模拟支付或自动安装。", "Commercial plugins require formal procurement or authorization. This demo site does not simulate payment or auto-installation.")); + const pluginId = payPlugin.id; + const price = payPlugin.price; setPayPlugin(null); + void doInstall(pluginId, { confirm_price: true, price }); } const filtered = search ? plugins.filter((p: any) => @@ -616,7 +625,7 @@ function PluginCenter() { {filtered.map((p: any) => { const isInstalled = installedIds.has(p.id); const isInstalling = installing.has(p.id); - const isFree = p.price === "免费" || p.price === "免费额度" || p.price === "free" || p.price === "free tier"; + const isFree = !p.paid; return (
@@ -632,7 +641,7 @@ function PluginCenter() { {isFree ? ( {t("免费", "Free")} ) : ( - {pluginPriceLabel(p.price, locale)} + {t("需确认价格", "Confirm price")} )}

{p.description}

diff --git a/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts b/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts index c49a2979..1082e1b3 100644 --- a/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts +++ b/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { AddressInfo } from "node:net"; +import express from "express"; async function main() { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "freeos-f10-")); @@ -131,6 +133,42 @@ async function main() { assert.equal(dbGet("SELECT password_hash FROM users WHERE email = ?", ["owner@example.com"]).password_hash, after.owner); assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [seededPluginId]).status, "disabled"); assert.equal(dbGet("SELECT learned_knowledge FROM reflections WHERE learned_knowledge LIKE '%operator rewrote%'").learned_knowledge, after.editedReflection); + + dbRun("UPDATE tenants SET status = 'active' WHERE id = 1"); + process.env.JWT_SECRET = "f10-plugin-list-secret-at-least-32-characters"; + process.env.COOKIE_SECRET = "f10-plugin-list-cookie-secret-at-least-32-chars"; + const { pluginRoutes } = await import("../backend/routes/plugins"); + const { signToken } = await import("../backend/middleware"); + const ownerRow = dbGet("SELECT id FROM users WHERE email = ?", ["owner@example.com"]) as { id: number }; + const token = signToken({ + id: ownerRow.id, + email: "owner@example.com", + nickname: "Owner", + role: "admin", + tenant_id: 1, + }); + const app = express(); + app.use(express.json()); + app.use("/api/plugins", pluginRoutes); + const server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server.once("listening", resolve)); + const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + try { + const listed = await fetch(`${base}/api/plugins`, { headers: { Authorization: `Bearer ${token}` } }); + const listedBody = await listed.json() as { data?: Array<{ name: string; status: string; paid?: boolean; price?: string }>; error?: string }; + assert.equal(listed.status, 200, JSON.stringify(listedBody)); + assert.equal(listedBody.data.some((row) => row.name === "AI合同智能解析" && row.status === "active"), false); + assert.equal(listedBody.data.some((row) => row.price), false); + const removed = await fetch(`${base}/api/plugins/${seededPluginId}/uninstall`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(removed.status, 200); + assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [seededPluginId]).status, "disabled"); + assert.ok(dbGet("SELECT id FROM plugins WHERE id = ?", [seededPluginId])); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } console.log("f10 upgrade cleanup ok"); } From 4aed65cff49007d16210d9ca07b8ef422ae1947b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:19:20 +0000 Subject: [PATCH 35/39] K security contact: replace the personal mailbox with the pending placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vulnerability contact line is now 「漏洞接收邮箱:待董事长指定」 and does not name an inbox. Co-authored-by: haoboy007-sudo --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index de44f56b..62d2cc49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ **Please do not open a public GitHub issue for security vulnerabilities.** - **GitHub Security Advisory:** [Create advisory](https://github.com/XYAIStudio/FreeOS/security/advisories/new) -- **Email:** jubaoliang@gmail.com +- 漏洞接收邮箱:待董事长指定 We aim to acknowledge reports within **3 business days** and provide a fix timeline within **7 business days** for confirmed issues. From 54231f28fd07c1ff22e7d4438f841e9000affce9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:24:00 +0000 Subject: [PATCH 36/39] F9 lint: format the new backup and bind-loopback tests Ruff format --check failed CI on these two files. Co-authored-by: haoboy007-sudo --- tests/unit/test_bind_loopback.py | 4 +++- tests/unit/test_upgrade_backup.py | 22 ++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_bind_loopback.py b/tests/unit/test_bind_loopback.py index bdb9ea3f..73f30966 100644 --- a/tests/unit/test_bind_loopback.py +++ b/tests/unit/test_bind_loopback.py @@ -18,6 +18,8 @@ def test_compose_publishes_loopback_and_documents_container_bind() -> None: entry = (REPO / "docker" / "docker-entrypoint.sh").read_text(encoding="utf-8") assert 'BIND_HOST="${OCTOP_BIND_HOST:-127.0.0.1}"' in entry assert "--host 0.0.0.0" not in entry - org = (REPO / "modules" / "openxyos" / "deploy" / "docker-compose.yml").read_text(encoding="utf-8") + org = (REPO / "modules" / "openxyos" / "deploy" / "docker-compose.yml").read_text( + encoding="utf-8" + ) assert "127.0.0.1:3000:3000" in org assert 'FREEOS_ORG_BIND_HOST: "0.0.0.0"' in org diff --git a/tests/unit/test_upgrade_backup.py b/tests/unit/test_upgrade_backup.py index c0c7083a..ead2a48f 100644 --- a/tests/unit/test_upgrade_backup.py +++ b/tests/unit/test_upgrade_backup.py @@ -39,12 +39,12 @@ def test_install_sh_backup_only_writes_archive_and_sha256(tmp_path: Path) -> Non "#!/bin/sh\n" "out=\n" "prev=\n" - "for arg in \"$@\"; do\n" - " if [ \"$prev\" = \"-o\" ]; then out=$arg; fi\n" + 'for arg in "$@"; do\n' + ' if [ "$prev" = "-o" ]; then out=$arg; fi\n' " prev=$arg\n" "done\n" - "mkdir -p \"$(dirname \"$out\")\"\n" - "printf backup > \"$out\"\n", + 'mkdir -p "$(dirname "$out")"\n' + 'printf backup > "$out"\n', encoding="utf-8", ) octop.chmod(0o755) @@ -108,14 +108,20 @@ def test_install_sh_backup_failure_aborts(tmp_path: Path) -> None: def test_nsis_overwrite_backs_up_before_copying_files() -> None: - nsi = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "project.nsi").read_text(encoding="utf-8") - tools = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "wails_tools.nsh").read_text(encoding="utf-8") + nsi = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "project.nsi").read_text( + encoding="utf-8" + ) + tools = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "wails_tools.nsh").read_text( + encoding="utf-8" + ) backup = nsi.index("wails.backupBeforeOverwrite") files = nsi.index("wails.files", backup) assert backup < files - assert "Abort" in tools[tools.index("wails.backupBeforeOverwrite"):] + assert "Abort" in tools[tools.index("wails.backupBeforeOverwrite") :] assert "backup-before-overwrite.ps1" in tools - script = (REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "backup-before-overwrite.ps1").read_text(encoding="utf-8") + script = ( + REPO / "desktop" / "src" / "build" / "windows" / "nsis" / "backup-before-overwrite.ps1" + ).read_text(encoding="utf-8") assert "--include-chats" in script assert "Upgrade aborted: backup failed" in script From 1c5597c1aac079cf724a81750516c91c8ee0286a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:44:04 +0000 Subject: [PATCH 37/39] fix test: custom agent create fake exposes agent_workspace create() now resolves the workspace through agent_workspace before ensure_agent_workspace. The unit fake only stubbed the old method, so CI make test failed with AttributeError. Co-authored-by: haoboy007-sudo --- tests/unit/agents/test_custom_agent_id.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/agents/test_custom_agent_id.py b/tests/unit/agents/test_custom_agent_id.py index 8a60a8e0..1896f1ef 100644 --- a/tests/unit/agents/test_custom_agent_id.py +++ b/tests/unit/agents/test_custom_agent_id.py @@ -62,7 +62,12 @@ def ensure_workspace(agent_id: str): agent_repo=SimpleNamespace(get=repo_get), user_policy_repo=SimpleNamespace(get=lambda *args, **kwargs: None), ) - mgr._paths = SimpleNamespace(ensure_agent_workspace=ensure_workspace) + # create() resolves the workspace through agent_workspace before ensure_agent_workspace. + mgr._paths = SimpleNamespace( + ensure_agent_workspace=ensure_workspace, + agent_workspace=ensure_workspace, + root="/tmp/freeos-home", + ) mgr._lock = asyncio.Lock() mgr._assert_agent_name_available = lambda user_id, name: None # type: ignore[method-assign] return mgr, WorkspaceReached From dc04535b1e77d8496675a424daec08934b767d32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 08:57:42 +0000 Subject: [PATCH 38/39] F9 tests: stub the desktop pre-upgrade backup by default Portable upgrade tests embed a fake python. After the full backup became mandatory, those tests executed it and failed with exec format error. The default stub writes an archive; the backup-specific tests still replace it. Co-authored-by: haoboy007-sudo --- desktop/src/download_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go index a2c76106..f1151217 100644 --- a/desktop/src/download_test.go +++ b/desktop/src/download_test.go @@ -22,6 +22,11 @@ func TestMain(m *testing.M) { if os.Getenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE") == "" { _ = os.Setenv("OCTOP_DESKTOP_INSTALL_STAMP_FILE", "") } + // Upgrade tests ship a fake python, not a runnable interpreter. The real + // backup command is covered by the tests that replace this stub. + runPreUpgradeBackup = func(_, _, _, dest string) error { + return os.WriteFile(dest, []byte("backup"), 0o600) + } os.Exit(m.Run()) } From df18457ccf1f970e5ca1889da44b921ab5228086 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 09:28:49 +0000 Subject: [PATCH 39/39] F9 tests: run install.sh backup behaviour only on POSIX install.sh exits immediately when uname is not Linux or Darwin. The Windows CI runner was executing that path through Git bash and failing before the backup hook. Co-authored-by: haoboy007-sudo --- tests/unit/test_upgrade_backup.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_upgrade_backup.py b/tests/unit/test_upgrade_backup.py index ead2a48f..ac299921 100644 --- a/tests/unit/test_upgrade_backup.py +++ b/tests/unit/test_upgrade_backup.py @@ -2,9 +2,13 @@ from __future__ import annotations +import os from pathlib import Path +import pytest + REPO = Path(__file__).resolve().parents[2] +posix_only = pytest.mark.skipif(os.name != "posix", reason="install.sh refuses non-POSIX uname") def test_install_sh_backs_up_before_package_install() -> None: @@ -29,6 +33,7 @@ def test_install_ps1_backs_up_before_venv_clear() -> None: assert "SHA256" in text +@posix_only def test_install_sh_backup_only_writes_archive_and_sha256(tmp_path: Path) -> None: home = tmp_path / "home" bin_dir = home / "venv" / "bin" @@ -76,6 +81,7 @@ def test_install_sh_backup_only_writes_archive_and_sha256(tmp_path: Path) -> Non assert os.path.getsize(archives[0]) > 0 +@posix_only def test_install_sh_backup_failure_aborts(tmp_path: Path) -> None: home = tmp_path / "home" bin_dir = home / "venv" / "bin"