diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 6786038e..8c8cf082 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: @@ -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" @@ -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) @@ -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,15 @@ 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 }} + + # 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/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index 65d5c623..f2a7420f 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 @@ -325,6 +360,8 @@ jobs: contents: write actions: read steps: + - uses: actions/checkout@v4 + - name: Resolve release tag id: rel run: | @@ -339,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. @@ -369,4 +410,15 @@ 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' }} + + # 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 cf626dad..4a7a57d4 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: @@ -54,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 @@ -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,10 @@ jobs: echo "" echo "## Install" echo "" + 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 "pip install octop==$version" + echo "uv pip install ./freeos-$version-py3-none-any.whl" echo '```' } > .release-notes.md fi @@ -137,12 +126,28 @@ 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' }} + + # 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. @@ -165,7 +170,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 +180,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/.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/CHANGELOG.md b/CHANGELOG.md index f87fb586..36d8dd5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,32 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。 -## [Unreleased] +## [0.1.0rc1] - TBD + +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 + +- 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). + +### Changed + +- 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 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.0rc1 + +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/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..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 @@ -29,7 +30,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 ──────────────────────────────────────────────────────────────────── @@ -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/README.md b/README.md index 55df7923..c2382be1 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,16 +136,18 @@ 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)。 +**不要**执行 `pip install octop`。PyPI 上的 `octop` 是上游 Octop,不是 FreeOS。本仓库的 Python 制品在 GitHub Releases 上,文件名是 `freeos--py3-none-any.whl` 和 `freeos-.tar.gz`(导入包仍是 `octop`)。桌面安装包仍是 `FreeOS-desktop-*`。 + +新安装的数据目录是 `%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 产出。 ### Prerequisites - Python 3.12+ (the project uses [uv](https://docs.astral.sh/uv/)) -- Node.js 20.19+ only if you opt into the organization sidecar (`FREEOS_ORG_SIDECAR=1`) from source, or rebuild the dashboard. A fresh FreeOS install does not need Node. The sidecar / iframe path is a [transition bridge](docs/product-contract.md), not the destination. +- Node.js 20.19+ only if you opt into the organization sidecar (`FREEOS_ORG_SIDECAR=1`) from source, or rebuild the dashboard. A fresh FreeOS install does not bundle or start Node: the default desktop packages and `docker/docker-compose.yml` are a single Python process (locked by `tests/unit/desktop/test_phase5_defaults.py`). The sidecar / iframe path is a [transition bridge](docs/product-contract.md), not the destination. ### From this repository @@ -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 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) @@ -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** 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 fd7b3dcc..89fa979f 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 @@ -92,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 做什么 @@ -129,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)。 ## 🤔 功能特性 @@ -193,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,也不是 `develop` 上的旧脚本): ```bash -curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/codex/freeos-0.1.0-upstream-foundation/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/codex/freeos-0.1.0-upstream-foundation/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/codex/freeos-0.1.0-upstream-foundation/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 @@ -225,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/codex/freeos-0.1.0-upstream-foundation/scripts/install.sh | bash -s -- --extras browser +curl -fsSL https://raw.githubusercontent.com/XYAIStudio/FreeOS/codex/freeos-0.1.0-upstream-foundation/scripts/install.sh | bash -s -- --extras channels-feishu ``` 完整安装选项见 [scripts/README.md](scripts/README.md)(`--version`、`--from-source`、`--mirror` 及 Windows 参数)。 @@ -244,18 +242,11 @@ 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。 -**备选 — PyPI**(若你已自行管理 Python 环境): - -```bash -pip install octop -# 可选:pip install "octop[browser]" -# 可选本地 ONNX Embedding 模型缓存(设置 → 模型 → 本地):pip install "octop[local-embedding]" -# 仅下载目录模型到 ~/.octop/embedding_models;不用于对话,也不接入 Memory。 -``` +**不要** `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 开发时: @@ -276,9 +267,11 @@ octop run --host 0.0.0.0 --port 8088 octop service start ``` -打开 **http://127.0.0.1:8088**。Docker 首次初始化会自动生成随机管理员密码(写入 `/data/.octop/credential.txt`),除非设置了 `OCTOP_DEFAULT_PASSWORD`。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。向导模型步默认先选本机 Ollama;知识库默认走本机文件夹 + ONNX 向量,云厂商与 ima 是可选项。详见 [docs/user-guide.md](docs/user-guide.md)。 +打开 **http://127.0.0.1:8088**。交互式 `octop init` / 设置向导会让你自行设置密码(至少 8 位,且同时包含字母和数字)。向导密码文件在产品家目录里的 `octop-login.txt`(`FREEOS_HOME` / `--home`,不是操作系统用户目录)。向导模型步默认先选本机 Ollama;知识库默认走本机文件夹 + ONNX 向量,云厂商与 ima 是可选项。详见 [docs/user-guide.md](docs/user-guide.md)。 -### Docker(推荐用于生产部署) +### Docker + +仓库里的 Compose 是**单进程 FreeOS**(不启动 Node sidecar)。`tests/unit/desktop/test_phase5_defaults.py` 与 `tests/unit/test_docker_compose_database_env.py` 锁定了这份文件的形状和环境变量。这不是单独认证过的生产配置;要用时按下面的家目录挂载。 ```bash # 构建并启动 @@ -288,13 +281,15 @@ docker compose -f docker/docker-compose.yml up -d bash docker/docker_build.sh docker run -d \ -p 8088:8088 \ - -v octop-data:/data/.octop \ + -v freeos-data:/data/.freeos \ -e HOME=/data \ + -e FREEOS_HOME=/data/.freeos \ + -e OCTOP_HOME=/data/.freeos \ -e OCTOP_DEFAULT_PASSWORD="<自定义强密码,留空则自动生成随机密码>" \ octop:latest ``` -打开 `http://localhost:8088`。首次初始化会创建管理员账号,并把凭据写入容器内 `/data/.octop/credential.txt`。未设置 `OCTOP_DEFAULT_PASSWORD` 时自动生成随机强密码;自行设置的密码须 ≥8 位且同时包含字母和数字(被应用密码策略拒绝的常见弱密码会自动回退为随机密码)。可通过 `OCTOP_ADMIN_USERNAME` 覆盖用户名。 +打开 `http://localhost:8088`。首次初始化会创建管理员账号,并把凭据写入容器内 `/data/.freeos/credential.txt`。未设置 `OCTOP_DEFAULT_PASSWORD` 时自动生成随机强密码;自行设置的密码须 ≥8 位且同时包含字母和数字(被应用密码策略拒绝的常见弱密码会自动回退为随机密码)。可通过 `OCTOP_ADMIN_USERNAME` 覆盖用户名。 > **密码策略:** 至少 8 位,且同时包含字母和数字。 @@ -303,7 +298,7 @@ docker run -d \ | `OCTOP_PORT` | `8088` | HTTP 监听端口 | | `OCTOP_DEFAULT_PASSWORD` | _(未设置)_ | 首次运行管理员密码(Docker 引导)。未设置 = 自动生成随机密码并写入 `credential.txt` | | `OCTOP_ADMIN_USERNAME` | `admin` | 首次运行管理员用户名 | -| `OCTOP_DATA` | `~/.octop` | 宿主机数据目录(compose 挂载) | +| `OCTOP_DATA` | `~/.freeos` | 宿主机数据目录(compose 挂载到容器 `FREEOS_HOME`) | 完整变量列表见 [`.env.example`](.env.example)。 @@ -339,18 +334,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` | -| PyPI | 全平台 | `pip install octop` 或 `pip install "octop[browser]"` | +| 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/codex/freeos-0.1.0-upstream-foundation/scripts/install.sh` | +| 本仓库脚本 | Windows | `scripts\install.ps1` / `install.bat`,或同分支的 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,若已注册系统服务则自动重启 @@ -360,7 +353,7 @@ octop update # 获取并安装最新版 Octop,若已注册系统服 ### ⚙️ 配置 -所有运行时数据存放在 `~/.octop/`。可通过 CLI 管理,也可直接编辑文件。 +新安装的运行时数据在 `~/.freeos/`。已有 `~/.octop/` 且还没有 `~/.freeos/` 时,CLI 继续用旧目录。可通过 CLI 管理,也可直接编辑文件。 ```bash # LLM 供应商与模型 @@ -401,7 +394,7 @@ octop user list | 命令 | 说明 | |------|------| -| `octop init` | 初始化 `~/.octop/`(数据库、管理员、JWT 密钥) | +| `octop init` | 初始化数据目录(新安装为 `~/.freeos/`;数据库、管理员、JWT 密钥) | | `octop run` | 前台启动 Octop | | `octop service start` | 安装并启动系统服务 | | `octop service stop` | 停止系统服务 | @@ -414,7 +407,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)**。 @@ -442,7 +435,7 @@ octop user list ### 📁 数据目录 ``` -~/.octop/ ← 安装与数据根目录 +~/.freeos/ ← 新安装的数据根目录(已有 ~/.octop 且没有 ~/.freeos 时仍用旧目录) ├── config.json # 进程配置(含可选 database 段) ├── octop.db # 默认 SQLite 控制面(用户、Agent、通道、定时任务 …) ├── secrets/ # JWT 密钥、通道 Token @@ -517,10 +510,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 通道。 ### 🤝 参与贡献 @@ -553,13 +546,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/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. diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index ff1fc723..7d81acd5 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", @@ -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.12", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -63,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": "^3.2.6" + "vitest": "^4.1.12" } }, "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", @@ -268,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", @@ -1807,20 +1791,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", @@ -1910,7 +1928,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1934,22 +1951,10 @@ } ], "license": "MIT", - "peer": true, "engines": { "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 +2803,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 +3141,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", @@ -3388,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", @@ -3805,16 +3799,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", @@ -3938,7 +3922,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", @@ -4376,7 +4361,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" @@ -4387,7 +4371,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" } @@ -4409,12 +4392,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", @@ -4477,7 +4454,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", @@ -4631,15 +4607,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 +4738,29 @@ } }, "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.12", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz", + "integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==", "dev": true, "license": "MIT", "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.12", + "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.12", + "vitest": "4.1.12" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4795,39 +4769,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.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": "3.2.6", - "@vitest/utils": "3.2.6", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "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.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "4.1.12", "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 +4824,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.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": { - "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.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.12", + "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.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": "3.2.6", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -4892,28 +4867,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.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "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.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": "3.2.6", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4941,8 +4913,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", @@ -4950,7 +4921,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4967,15 +4937,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 +5168,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 +5199,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 +5317,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 +5330,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 +5359,13 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, + "license": "MIT", "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 +5380,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 +5438,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 +5468,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 +5531,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 +5582,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 +5600,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 +5662,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 +5743,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 +5774,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,11 +5802,10 @@ "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": { "node": ">=0.10" } @@ -6358,7 +6203,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" } @@ -6604,16 +6448,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", @@ -6621,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", @@ -6730,12 +6555,13 @@ "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.7", - "resolved": "https://mirrors.tencent.com/npm/dompurify/-/dompurify-3.4.7.tgz", - "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "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" @@ -6756,13 +6582,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 +6614,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 +6632,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 +6755,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" }, @@ -7061,7 +6881,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", @@ -7300,9 +7119,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.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -7313,7 +7132,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fault": { "version": "1.0.4", @@ -7346,12 +7166,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 +7189,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 +7251,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 +7309,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 +7517,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": { @@ -8173,7 +7974,6 @@ "url": "https://www.locize.com" } ], - "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -8730,6 +8530,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 +8725,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 +8785,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 +8896,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 +8975,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==", + "node_modules/katex": { + "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": { - "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==", - "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 +9014,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 +9139,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 +9160,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", @@ -9476,6 +9198,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9491,15 +9214,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 +9268,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,31 +9617,55 @@ } }, "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/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": { @@ -10579,28 +10318,21 @@ } }, "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", + "peer": true, "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", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10608,23 +10340,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 +10389,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 +10446,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,22 +10708,11 @@ "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", "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=20.16.0 || >=22.3.0" }, @@ -11067,9 +10786,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 +10804,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" @@ -11138,6 +10858,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11153,6 +10874,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -11165,7 +10887,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", @@ -11181,11 +10904,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", @@ -11839,7 +11557,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" }, @@ -11852,7 +11569,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" @@ -11982,7 +11698,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" @@ -12011,9 +11726,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 +11748,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" @@ -12140,8 +11855,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", @@ -12442,7 +12156,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" }, @@ -12620,15 +12333,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", @@ -12691,9 +12395,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": { @@ -12702,7 +12406,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 +12600,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 +12640,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 +12654,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 +12704,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 +12830,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 +12865,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 +12975,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 +12984,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 +13023,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 +13053,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 +13091,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", @@ -13899,7 +13234,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13932,14 +13266,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 +13483,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": [ { @@ -14298,12 +13624,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", @@ -14373,29 +13698,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 +13730,79 @@ } }, "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.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "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.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", + "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.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": "*" + "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 +13813,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 +13841,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 +14030,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", @@ -14907,7 +14131,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", @@ -15107,34 +14330,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 +14353,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..f66155e3 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", @@ -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,22 @@ "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", + "vite": "^6.4.5", + "deepmerge": "4.1.2" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -67,7 +70,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.12", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", @@ -79,8 +82,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": "^3.2.6" + "vitest": "^4.1.12" } } 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/locales/en.json b/dashboard/src/locales/en.json index d361d312..a88c128c 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -440,7 +440,10 @@ "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.", + "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 3cc0ad3e..dcb165c7 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -440,7 +440,10 @@ "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}})。请修正语法后重试;现有配置未被改动。", + "HOME_WRITE_REFUSED": "拒绝写入 {{path}}。该目录已存在,且不在当前家目录({{home}})之内。", + "AGENT_SEED_EXISTS": "拒绝把默认智能体播种到已有数据上:{{path}}。" }, "nav": { "chat": "对话", 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/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", + )}`} + + ) : ( + + ))}

@@ -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/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/modules/openxyos/frontend/src/pages/OpenHomePage.tsx b/modules/openxyos/frontend/src/pages/OpenHomePage.tsx index f160ae10..71bf6a3b 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/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/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 caa4fcbf..c6e19d49 100644 --- a/modules/openxyos/package-lock.json +++ b/modules/openxyos/package-lock.json @@ -36,7 +36,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": { @@ -70,12 +70,12 @@ "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" }, "engines": { - "node": ">=20.19" + "node": ">=22.12" } }, "node_modules/@antfu/install-pkg": { @@ -1953,18 +1953,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", @@ -1976,17 +1964,6 @@ "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", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -2752,9 +2729,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 +2748,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 +2771,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 +2785,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.4" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -2818,9 +2795,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 +2812,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 +2829,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 +2846,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 +2863,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 +2880,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 +2897,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 +2914,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 +2931,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 +2948,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 +2965,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 +2984,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 +3007,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 +3030,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 +3053,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 +3076,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 +3099,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 +3122,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 +3145,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 +3166,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 +3176,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 +3186,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 +3206,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 +3226,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" ], @@ -3616,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", @@ -4266,6 +4253,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, @@ -5202,9 +5210,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": { @@ -5506,13 +5514,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 +5622,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" } @@ -5784,9 +5789,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", @@ -5835,9 +5840,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", @@ -5945,6 +5950,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 +6234,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" @@ -7185,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", @@ -7324,9 +7305,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.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" @@ -8007,15 +7988,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 +8112,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.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -8214,9 +8186,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": { @@ -8308,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", @@ -9017,9 +8989,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.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" @@ -9582,13 +9554,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 +9846,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 +9856,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 +10451,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 +10662,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.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", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -11386,9 +11348,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 +11358,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -11418,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", @@ -11588,9 +11554,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" @@ -12052,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": { @@ -12145,9 +12111,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 +12128,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 +12197,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 +12319,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 +12358,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", @@ -13064,12 +13024,6 @@ "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", @@ -13134,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": { @@ -13347,16 +13301,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": { @@ -13369,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": { @@ -14203,9 +14157,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..7a43451b 100644 --- a/modules/openxyos/package.json +++ b/modules/openxyos/package.json @@ -11,7 +11,12 @@ "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: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", "test:cors-origins": "tsx scripts/test-cors-origins.ts", @@ -30,12 +35,13 @@ "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", "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 +71,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": { @@ -99,15 +105,25 @@ "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" }, "description": "Open-source operating system for governed human-agent organizations", "engines": { - "node": ">=20.19" + "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", + "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/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts b/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts new file mode 100644 index 00000000..1082e1b3 --- /dev/null +++ b/modules/openxyos/scripts/test-f10-upgrade-cleanup.ts @@ -0,0 +1,178 @@ +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-")); + process.env.DATABASE_PATH = path.join(dir, "xiongyuan.db"); + process.env.FREEOS_ORG_INTEGRATED = "1"; + delete process.env.FREEOS_ORG_LOCAL_TEST; + delete process.env.FREEOS_ORG_DEV_BOOTSTRAP; + delete process.env.FREEOS_DEV_BUILD; + delete process.env.SEED_DEMO_DATA; + + const { initDatabase, dbRun, dbGet } = await import("../backend/db"); + const { cleanupV010SeedResidue, F10_MARKER, P16_ERROR_LEARNING, P16_KNOWLEDGE, SEED_PLUGIN } = await import("../backend/upgrade-cleanup"); + + await initDatabase(); + assert.equal(dbGet("SELECT id FROM users WHERE email = ?", ["demo@demo.com"]), undefined); + assert.equal( + dbGet("SELECT marker FROM org_upgrade_markers WHERE marker = ?", [F10_MARKER])?.marker, + F10_MARKER, + ); + const emptyDetail = dbGet("SELECT detail FROM org_upgrade_markers WHERE marker = ?", [F10_MARKER]) as { detail: string }; + assert.match(emptyDetail.detail, /no v0.0.x seed residue/); + const usersBeforeSecondEmptyRun = Number(dbGet("SELECT COUNT(*) as c FROM users").c); + cleanupV010SeedResidue(); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM users").c), usersBeforeSecondEmptyRun); + + dbRun("DELETE FROM org_upgrade_markers WHERE marker = ?", [F10_MARKER]); + dbRun("INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (1, 'openXYOS', 'openxyos', 'suspended', 'community')"); + dbRun("UPDATE tenants SET status = 'suspended' WHERE id = 1"); + dbRun("INSERT OR IGNORE INTO tenants (id, name, slug, status, plan) VALUES (2, 'local', 'openxyos-local', 'trial', 'community')"); + dbRun("UPDATE tenants SET status = 'trial' WHERE id = 2"); + + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'admin', 2, 0)", + ["demo@demo.com", "seed-hash-demo", "管理员"], + ); + const colleagueId = Number(dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'user', 2, 0)", + ["user@demo.com", "seed-hash-user", "同事"], + ).lastInsertRowid); + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'super_admin', 1, 0)", + ["admin@example.com", "seed-hash-admin", "超级管理员"], + ); + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'admin', 1, 0)", + ["owner@example.com", "hash-keep", "Owner"], + ); + dbRun("INSERT INTO chats (title, created_by, tenant_id) VALUES ('owned', ?, 2)", [colleagueId]); + dbRun( + `INSERT INTO reflections (tenant_id, employee_id, task_id, reflection_type, success_factors, failure_reasons, knowledge_gaps, improvement_plans, extracted_skills, learned_knowledge, importance_score) + VALUES (1, 0, NULL, 'error_learning', NULL, ?, ?, ?, ?, ?, 95)`, + [ + P16_ERROR_LEARNING.failure_reasons, + P16_ERROR_LEARNING.knowledge_gaps, + P16_ERROR_LEARNING.improvement_plans, + P16_ERROR_LEARNING.extracted_skills, + P16_ERROR_LEARNING.learned_knowledge, + ], + ); + dbRun( + `INSERT INTO reflections (tenant_id, employee_id, task_id, reflection_type, success_factors, failure_reasons, knowledge_gaps, improvement_plans, extracted_skills, learned_knowledge, importance_score) + VALUES (1, 0, NULL, 'knowledge_capture', ?, NULL, NULL, NULL, ?, ?, 98)`, + [P16_KNOWLEDGE.success_factors, P16_KNOWLEDGE.extracted_skills, P16_KNOWLEDGE.learned_knowledge], + ); + dbRun( + `INSERT INTO reflections (tenant_id, employee_id, task_id, reflection_type, success_factors, learned_knowledge, importance_score) + VALUES (1, 0, NULL, 'knowledge_capture', ?, ?, 98)`, + ["edited by a person", "部署铁律 but the operator rewrote this paragraph"], + ); + dbRun( + `INSERT INTO reflections (tenant_id, employee_id, reflection_type, learned_knowledge, importance_score) + VALUES (1, 7, 'task_completion', 'unrelated note', 10)`, + ); + const seededPluginId = Number(dbRun( + `INSERT INTO plugins (tenant_id, name, slug, category, description, icon, version, author, price, tags, install_count, rating, status) + VALUES (1, ?, ?, ?, ?, '🤖', ?, ?, ?, ?, 0, 5.0, 'active')`, + [SEED_PLUGIN.name, SEED_PLUGIN.slug, SEED_PLUGIN.category, SEED_PLUGIN.description, SEED_PLUGIN.version, SEED_PLUGIN.author, SEED_PLUGIN.price, SEED_PLUGIN.tags], + ).lastInsertRowid); + const editedPluginId = Number(dbRun( + `INSERT INTO plugins (tenant_id, name, slug, category, description, version, author, price, tags, install_count, rating, status, config_json) + VALUES (2, ?, ?, '法务合规', 'user edited', '1.0.0', '雄元科技', '¥99/月', 'AI', 12, 4.2, 'active', '{"enabled":true}')`, + [SEED_PLUGIN.name, SEED_PLUGIN.slug], + ).lastInsertRowid); + + cleanupV010SeedResidue(); + + const demo = dbGet("SELECT status, must_change_password, password_hash FROM users WHERE email = ?", ["demo@demo.com"]) as any; + assert.equal(demo.status, "disabled"); + assert.equal(Number(demo.must_change_password), 1); + assert.notEqual(demo.password_hash, "seed-hash-demo"); + const colleague = dbGet("SELECT status, must_change_password, password_hash FROM users WHERE email = ?", ["user@demo.com"]) as any; + assert.equal(colleague.status, "active"); + assert.equal(Number(colleague.must_change_password), 1); + assert.notEqual(colleague.password_hash, "seed-hash-user"); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM chats WHERE created_by = ?", [colleagueId]).c), 1); + const admin = dbGet("SELECT status, must_change_password, password_hash FROM users WHERE email = ?", ["admin@example.com"]) as any; + assert.equal(admin.status, "disabled"); + assert.equal(Number(admin.must_change_password), 1); + assert.notEqual(admin.password_hash, "seed-hash-admin"); + const owner = dbGet("SELECT status, must_change_password, password_hash FROM users WHERE email = ?", ["owner@example.com"]) as any; + assert.equal(owner.password_hash, "hash-keep"); + assert.equal(Number(owner.must_change_password), 0); + assert.notEqual(owner.status, "disabled"); + assert.equal(dbGet("SELECT status FROM tenants WHERE id = 1").status, "suspended"); + assert.equal(dbGet("SELECT status FROM tenants WHERE id = 2").status, "trial"); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM reflections WHERE failure_reasons LIKE '%const 临时死区%'").c), 0); + const edited = dbGet("SELECT learned_knowledge FROM reflections WHERE learned_knowledge LIKE '%operator rewrote%'") as any; + assert.match(edited.learned_knowledge, /operator rewrote/); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM reflections WHERE employee_id = 7").c), 1); + const seededPlugin = dbGet("SELECT id, status FROM plugins WHERE id = ?", [seededPluginId]) as any; + assert.equal(seededPlugin.status, "disabled"); + assert.equal(dbGet("SELECT status FROM plugins WHERE id = ?", [editedPluginId]).status, "active"); + assert.equal(Number(dbGet("SELECT COUNT(*) as c FROM workflow_audit_log WHERE action LIKE 'f10_%'").c) > 0, true); + + const after = { + demo: demo.password_hash, + colleague: colleague.password_hash, + admin: admin.password_hash, + owner: owner.password_hash, + plugin: seededPlugin.status, + editedReflection: edited.learned_knowledge, + }; + cleanupV010SeedResidue(); + assert.equal(dbGet("SELECT password_hash FROM users WHERE email = ?", ["demo@demo.com"]).password_hash, after.demo); + assert.equal(dbGet("SELECT password_hash FROM users WHERE email = ?", ["user@demo.com"]).password_hash, after.colleague); + assert.equal(dbGet("SELECT password_hash FROM users WHERE email = ?", ["admin@example.com"]).password_hash, after.admin); + 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"); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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; +}); 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 { 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/scripts/test-no-release-bootstrap.ts b/modules/openxyos/scripts/test-no-release-bootstrap.ts new file mode 100644 index 00000000..c811c220 --- /dev/null +++ b/modules/openxyos/scripts/test-no-release-bootstrap.ts @@ -0,0 +1,68 @@ +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-seed-")); + const dbPath = path.join(dir, "xiongyuan.db"); + process.env.DATABASE_PATH = dbPath; + process.env.FREEOS_ORG_INTEGRATED = "1"; + delete process.env.FREEOS_ORG_LOCAL_TEST; + delete process.env.FREEOS_ORG_DEV_BOOTSTRAP; + delete process.env.SEED_DEMO_DATA; + delete process.env.SEED_ADMIN_PASSWORD; + delete process.env.SEED_DEMO_PASSWORD; + + const { initDatabase, saveDb, dbRun, dbGet } = await import("../backend/db"); + const { seedDatabase } = await import("../backend/seed"); + await initDatabase(); + dbRun( + "INSERT INTO tenants (name, slug, status, plan) VALUES (?, ?, 'suspended', 'community')", + ["Acme", "acme-suspended"], + ); + dbRun( + "INSERT INTO users (email, password_hash, nickname, role, tenant_id, must_change_password) VALUES (?, ?, ?, 'admin', 1, 0)", + ["owner@example.com", "hash-keep", "Owner"], + ); + saveDb(); + + const snapshot = () => ({ + users: dbGet("SELECT email, password_hash, must_change_password FROM users WHERE email = ?", [ + "owner@example.com", + ]), + tenant: dbGet("SELECT status FROM tenants WHERE slug = ?", ["acme-suspended"]), + demo: dbGet("SELECT id FROM users WHERE email = ?", ["demo@demo.com"]), + colleague: dbGet("SELECT id FROM users WHERE email = ?", ["user@demo.com"]), + }); + const before = snapshot(); + 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"); + assert.equal(before.tenant.status, "suspended"); + assert.equal(fs.existsSync(path.join(dir, "bootstrap-credentials.txt")), false); + + dbRun( + "DELETE FROM reflections WHERE tenant_id = 1 AND employee_id = 0 AND (failure_reasons LIKE '%const 死区%' OR learned_knowledge LIKE '%部署铁律%')", + ); + saveDb(); + const reflectionCount = () => + dbGet( + "SELECT COUNT(*) as c FROM reflections WHERE tenant_id = 1 AND employee_id = 0 AND (failure_reasons LIKE '%const 死区%' OR learned_knowledge LIKE '%部署铁律%')", + ); + assert.equal(Number(reflectionCount().c), 0); + dbRun("DELETE FROM plugins WHERE slug = ?", ["ai合同智能解析"]); + saveDb(); + const pluginCount = () => + dbGet("SELECT COUNT(*) as c FROM plugins WHERE slug = ?", ["ai合同智能解析"]); + assert.equal(Number(pluginCount().c), 0); + for (let i = 0; i < 3; i += 1) await initDatabase(); + assert.equal(Number(reflectionCount().c), 0); + assert.equal(Number(pluginCount().c), 0); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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; +}); 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/pyproject.toml b/pyproject.toml index 63fae65b..4dba70a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling>=1.18"] build-backend = "hatchling.build" [project] -name = "octop" -version = "0.0.10" -description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" +name = "freeos" +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" } requires-python = ">=3.12" diff --git a/scripts/README.md b/scripts/README.md index 72997813..74e61d01 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/codex/freeos-0.1.0-upstream-foundation/scripts/install.sh \| bash` | +| Windows (PowerShell) | `irm https://raw.githubusercontent.com/XYAIStudio/FreeOS/codex/freeos-0.1.0-upstream-foundation/scripts/install.ps1 \| iex` | +| Windows (cmd) | 下载 `https://raw.githubusercontent.com/XYAIStudio/FreeOS/codex/freeos-0.1.0-upstream-foundation/scripts/install.bat` 后运行 | ### 本地脚本(仓库内) @@ -25,11 +29,14 @@ ### 常用选项 ```bash -# 从 PyPI 安装最新版(默认) +# 从 FreeOS GitHub 源码安装最新版(默认) bash scripts/install.sh -# 指定版本 -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 # 安装可选附加组件 bash scripts/install.sh --extras browser @@ -39,17 +46,18 @@ 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`。 ### 环境变量 | 变量 | 说明 | |------|------| -| `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`) | @@ -65,7 +73,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 +89,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/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/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 493da44e..01946ca3 100644 --- a/scripts/install-octop.sh +++ b/scripts/install-octop.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Octop 安装脚本 (macOS / Linux) -# 用法: bash scripts/install.sh # 从 PyPI 安装(默认) -# 或: bash scripts/install.sh --from-source # 从本地源码安装 +# 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 / 重开即可直接使用。 @@ -34,7 +34,7 @@ else fi VERSION="" -FROM_SOURCE=false +FROM_SOURCE=true EXTRAS="" PYPI_MIRROR="" @@ -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,14 +76,14 @@ while [[ $# -gt 0 ]]; do PYPI_MIRROR="$2"; shift 2 ;; -h|--help) cat < 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 @@ -123,7 +123,7 @@ case "$OS" in *) die "Unsupported OS: $OS. Use install.ps1 or install.bat on Windows." ;; esac -printf "${GREEN}[octop]${RESET} Installing Octop into ${BOLD}%s${RESET}\n" "$OCTOP_HOME" +printf "${GREEN}[freeos]${RESET} Installing FreeOS into ${BOLD}%s${RESET}\n" "$OCTOP_HOME" # ── 步骤 1: 确保 uv 可用 ──────────────────────────────────────────────────── _install_uv_via_pip() { @@ -562,7 +562,7 @@ if [ "$OS" = "Linux" ]; then fi _ensure_old_glibc_build_toolchain -# ── 步骤 3: 安装 Octop ─────────────────────────────────────────────────────── +# ── 步骤 3: 安装 FreeOS ────────────────────────────────────────────────────── # playwright Python 包已是核心依赖;Chromium 浏览器仅在 --extras browser 时下载。 _merge_install_extras() { local result="browser" @@ -644,11 +644,15 @@ _clone_source_workspace() { local workdir="$1" command -v git &>/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" - 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 @@ -688,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')" @@ -911,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 @@ -1019,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 @@ -1029,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 @@ -1070,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 8c500688..c407f18c 100644 --- a/scripts/install.bat +++ b/scripts/install.bat @@ -1,17 +1,27 @@ @echo off setlocal EnableDelayedExpansion -REM Octop Installer for Windows (cmd.exe) -REM Usage: install.bat [-Version X.Y.Z] [-FromSource] [-SourceDir DIR] [-Extras browser] [-Help] +REM FreeOS 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" -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 +31,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) @@ -29,11 +40,12 @@ 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 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 @@ -43,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%]" @@ -76,17 +88,21 @@ 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%" -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%" @@ -100,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 @@ -119,39 +135,41 @@ 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 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%" -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. @@ -199,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 ) @@ -208,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 @@ -220,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 @@ -242,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 @@ -250,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 48c4cb1c..e74bba83 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,13 +1,15 @@ -# 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"] # -# 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( [string]$Version = "", [switch]$FromSource, + [switch]$FromPyPI, [string]$SourceDir = "", [string]$Extras = "", [string]$UvPath = "", @@ -16,25 +18,42 @@ 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" -$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 } +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] 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 @@ -45,13 +64,16 @@ 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 } -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") { @@ -141,6 +163,21 @@ Ensure-Uv if (Test-Path $OctopVenv) { Write-Info "Existing environment found, upgrading..." + $existingCli = Join-Path $OctopVenv "Scripts\octop.exe" + $hasData = (Test-Path (Join-Path $OctopHome "octop.db")) -or (Test-Path (Join-Path $OctopHome "config.json")) + if ((Test-Path $existingCli) -and $hasData) { + $backupDir = Join-Path $OctopHome "backups" + New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + $stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ") + $backupOut = Join-Path $backupDir "pre-upgrade-$stamp.tar.gz" + Write-Info "Full backup before upgrade: $backupOut" + & $existingCli backup create --home $OctopHome --include-chats -o $backupOut + if ($LASTEXITCODE -ne 0) { Stop-WithError "Upgrade aborted: backup failed" } + $hash = (Get-FileHash -Algorithm SHA256 -Path $backupOut).Hash.ToLower() + $leaf = Split-Path -Leaf $backupOut + Set-Content -Encoding ascii -Path "$backupOut.sha256" -Value "$hash $leaf" + Write-Info "Backup sha256 written to $backupOut.sha256" + } } else { Write-Info "Creating Python $PythonVersion environment..." } @@ -226,7 +263,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 { @@ -247,7 +288,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 @@ -309,10 +350,22 @@ 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" + Write-Error "FreeOS environment not found at $OctopHome\venv" exit 1 } & $RealBin @args @@ -321,11 +374,20 @@ 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 + echo Error: FreeOS environment not found at %OCTOP_HOME%\venv >&2 exit /b 1 ) "%REAL_BIN%" %* @@ -356,7 +418,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 15138c8c..8eda097c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,17 +1,45 @@ #!/usr/bin/env bash -# Octop 安装脚本 (macOS / Linux) -# 用法: bash scripts/install.sh # 从 PyPI 安装(默认) -# 或: bash scripts/install.sh --from-source # 从本地源码安装 +# FreeOS 安装脚本 (macOS / Linux) +# 用法: bash scripts/install.sh # 从 FreeOS GitHub 源码安装(默认) +# 或: 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" @@ -34,7 +62,7 @@ else fi VERSION="" -FROM_SOURCE=false +FROM_SOURCE=true EXTRAS="" PYPI_MIRROR="" @@ -49,11 +77,48 @@ 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; } +_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 @@ -76,14 +141,14 @@ while [[ $# -gt 0 ]]; do PYPI_MIRROR="$2"; shift 2 ;; -h|--help) cat < 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 @@ -94,7 +159,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 +172,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) @@ -123,7 +192,15 @@ case "$OS" in *) die "Unsupported OS: $OS. Use install.ps1 or install.bat on Windows." ;; esac -printf "${GREEN}[octop]${RESET} Installing Octop into ${BOLD}%s${RESET}\n" "$OCTOP_HOME" +# 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 可用 ──────────────────────────────────────────────────── _install_uv_via_pip() { @@ -562,7 +639,7 @@ if [ "$OS" = "Linux" ]; then fi _ensure_old_glibc_build_toolchain -# ── 步骤 3: 安装 Octop ─────────────────────────────────────────────────────── +# ── 步骤 3: 安装 FreeOS ────────────────────────────────────────────────────── # playwright Python 包已是核心依赖;Chromium 浏览器仅在 --extras browser 时下载。 _merge_install_extras() { local result="browser" @@ -644,13 +721,19 @@ _clone_source_workspace() { local workdir="$1" command -v git &>/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" - 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 } +_backup_before_upgrade + if [ "$FROM_SOURCE" = true ]; then if [ -n "$SOURCE_DIR" ]; then info "Installing from local source: $SOURCE_DIR" @@ -688,7 +771,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')" @@ -946,14 +1029,39 @@ 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 - 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 @@ -1054,7 +1162,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 @@ -1064,7 +1172,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 @@ -1115,7 +1223,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/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..a9b2613d --- /dev/null +++ b/scripts/publish_sha256sums.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# 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 +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 + # 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 + 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 00378222..deb68c8d 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( [ "", @@ -89,6 +89,19 @@ 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')}", + "", + "### Checksums", + "", + f"- {md_link('SHA256SUMS', tag, 'SHA256SUMS')}", + "", ] ) 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/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" < bool: + """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. + 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}") + # 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)), + key=lambda path: 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 307eb09e..ca29e732 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.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/api/routers/filesystem.py b/src/octop/api/routers/filesystem.py index de098856..79f5afff 100644 --- a/src/octop/api/routers/filesystem.py +++ b/src/octop/api/routers/filesystem.py @@ -8,7 +8,7 @@ ``/etc``, ``/root`` on POSIX). The process home is never denied (so uid 0 with home ``/root`` can use the default picker path). - All authenticated users may browse from host root ``/`` (denylist still applies). - The UI default ``root_dir`` remains the process home directory. + The UI default ``root_dir`` is the FreeOS product home (``FREEOS_HOME`` / ``--home``). - Directory listing is capped and skips unreadable entries. - Write probe creates a short-lived dotfile only for non-``/`` selections. - mkdir / rename only allow basename-safe names under already-browsable parents. @@ -73,8 +73,9 @@ async def filesystem_defaults( user: User = Depends(current_user), server: Any = Depends(get_server), ) -> 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/local_models.py b/src/octop/api/routers/local_models.py index 342f29d7..1cb02964 100644 --- a/src/octop/api/routers/local_models.py +++ b/src/octop/api/routers/local_models.py @@ -39,7 +39,10 @@ list_download_jobs, start_download_job, ) -from octop.infra.agents.providers.local_probe import probe_local_models +from octop.infra.agents.providers.local_probe import ( + http_endpoint_reachable, + probe_local_models, +) from octop.infra.agents.providers.local_register import ( ensure_ollama_service_flag, find_ollama_row, @@ -183,6 +186,16 @@ async def local_models_probe( server: Any = Depends(get_server), ) -> dict[str, Any]: payload = await asyncio.to_thread(probe_local_models) + if not payload.get("reachable_provider") and server.services is not None: + for row in server.services.provider_repo.list_all(): + if not getattr(row, "enabled", 0): + continue + base = str(getattr(row, "base_url", "") or "").strip() + if not base: + continue + if await asyncio.to_thread(http_endpoint_reachable, base): + payload["reachable_provider"] = True + break registered = await asyncio.to_thread(load_registered, server.services.settings_repo) merged = _merge_registered(payload, registered) user_row = server.services.user_repo.get(user.id) 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/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/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/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/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/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/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/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..c45f4f5c 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -405,7 +405,10 @@ "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.", + "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 9ed4b607..57385a9a 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -405,7 +405,10 @@ "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})。请修正语法后重试;现有配置未被改动。", + "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/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/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/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/providers/local_probe.py b/src/octop/infra/agents/providers/local_probe.py index 29910e40..641ea9b3 100644 --- a/src/octop/infra/agents/providers/local_probe.py +++ b/src/octop/infra/agents/providers/local_probe.py @@ -6,6 +6,8 @@ import platform import shutil import subprocess +import urllib.error +import urllib.request from pathlib import Path from typing import Any @@ -178,4 +180,25 @@ def probe_local_models() -> dict[str, Any]: ), "installed": discovered, "recommended": recommended_catalog(ram), + "reachable_provider": bool(ollama_up or llamacpp_up), } + + +def http_endpoint_reachable(base_url: str, *, timeout: float = 2.0) -> bool: + """True when *base_url* answers an HTTP request (including 401/404). + + Connection failures are not reachable. A response under 500 means the + provider process is up; this is a connectivity check, not a chat completion. + """ + raw = (base_url or "").strip().rstrip("/") + if not raw.startswith(("http://", "https://")): + return False + target = raw if raw.endswith("/models") else f"{raw}/models" + request = urllib.request.Request(target, method="GET") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 + return int(response.status) < 500 + except urllib.error.HTTPError as exc: + return int(exc.code) < 500 + except (OSError, TimeoutError, urllib.error.URLError, ValueError): + return False diff --git a/src/octop/infra/agents/workspace_dir.py b/src/octop/infra/agents/workspace_dir.py index e0dd456e..29ea5f19 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: @@ -348,6 +449,105 @@ def skills_discovery_roots(workspace_dir: Path) -> list[Path]: return [workspace_dir / DEFAULT_SYSTEM_FILES_PATH / "skills", workspace_dir / "skills"] +def _looks_host_absolute(raw: str) -> bool: + text = raw.strip() + if _is_windows_style_host_path(text): + return True + return Path(text).is_absolute() + + +def _outside_product_home(raw: str, home: Path) -> bool: + text = raw.strip() + if not text or text in {"/", "\\"} or text.startswith("/.octop/"): + return False + if _is_windows_style_host_path(text) and os.name != "nt": + return True + if not _looks_host_absolute(text): + return False + try: + return not path_is_under_home(Path(text), home) + except OSError: + return True + + +def workspace_suffix_for_agent(raw: str, agent_id: str) -> str: + """Archive-relative workspace tail, independent of the machine that wrote it.""" + text = raw.replace("\\", "/").strip() + lowered = text.lower() + for needle in ( + f"/.octop/workspaces/{agent_id}", + f"/agents/{agent_id}", + f"/workspaces/{agent_id}", + ): + idx = lowered.rfind(needle.lower()) + if idx >= 0: + return text[idx + 1 :].lstrip("/") + return f"agents/{agent_id}" + + +def confine_workspace_config(cfg: dict[str, Any], *, home: Path, agent_id: str) -> dict[str, Any]: + """Copy *cfg* so workspace files stay inside *home*. + + Absolute paths from another install are rewritten to a suffix under *home*. + Agent-facing ``/.octop/...`` paths stay, with ``root_dir`` clamped to *home* + when it pointed outside. + """ + out = dict(cfg) + home_r = home.resolve() + backend = out.get("backend") + if isinstance(backend, dict): + backend = dict(backend) + root = backend.get("root_dir") + if isinstance(root, str) and _outside_product_home(root, home_r): + backend["root_dir"] = str(home_r) + out["backend"] = backend + raw = out.get("workspace_dir") + if isinstance(raw, str) and raw.strip(): + text = raw.strip() + if text.startswith("/.octop/") or text == "/": + out["workspace_dir"] = text + elif not _looks_host_absolute(text): + out["workspace_dir"] = text.replace("\\", "/").lstrip("/") + elif _outside_product_home(text, home_r): + out["workspace_dir"] = workspace_suffix_for_agent(text, agent_id) + else: + out["workspace_dir"] = Path(text).resolve().relative_to(home_r).as_posix() + else: + out["workspace_dir"] = f"agents/{agent_id}" + return out + + +def restored_workspace_dir( + config_json: str | None, + *, + paths: PathLayout, + agent_id: str, + ensure: bool = False, +) -> Path: + """On-disk restore target that is always inside ``paths.root``. + + Relative paths and ``/.octop/...`` paths are joined onto the product home. + Absolute paths from another machine are remapped by their workspace suffix. + """ + try: + parsed = json.loads(config_json or "{}") + except (json.JSONDecodeError, TypeError): + parsed = {} + cfg = parsed if isinstance(parsed, dict) else {} + confined = confine_workspace_config(cfg, home=paths.root, agent_id=agent_id) + text = str(confined.get("workspace_dir") or f"agents/{agent_id}").strip() + host = (paths.root / text.replace("\\", "/").lstrip("/")).resolve() + if not path_is_under_home(host, paths.root): + raise OctopError( + ErrorCode.HOME_WRITE_REFUSED, + f"refusing to restore agent {agent_id} workspace outside {paths.root}", + details={"path": str(host), "home": str(paths.root)}, + ) + if ensure: + host.mkdir(parents=True, exist_ok=True) + return host + + def workspace_dir_from_config_json( config_json: str | None, *, @@ -374,6 +574,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/backup/system_archive.py b/src/octop/infra/backup/system_archive.py index 01d9daa4..4ca0709b 100644 --- a/src/octop/infra/backup/system_archive.py +++ b/src/octop/infra/backup/system_archive.py @@ -16,6 +16,8 @@ from octop import __version__ from octop.config import DatabaseConfig from octop.infra.agents.workspace_dir import ( + confine_workspace_config, + restored_workspace_dir, system_files_path_from_config, workspace_dir_from_config_json, ) @@ -196,6 +198,56 @@ def _build_manifest( ) +def _relativize_sqlite_workspace_paths(db_file: Path, home: Path) -> None: + """Store workspace paths relative to *home* in a backup snapshot, not the live DB.""" + import sqlite3 + + conn = sqlite3.connect(db_file) + try: + try: + rows = conn.execute("SELECT agent_id, config_json FROM agents").fetchall() + except sqlite3.Error: + return + for agent_id, raw in rows: + try: + cfg = json.loads(raw or "{}") + except json.JSONDecodeError: + continue + if not isinstance(cfg, dict): + continue + confined = confine_workspace_config(cfg, home=home, agent_id=str(agent_id)) + encoded = json.dumps(confined) + if encoded != (raw or ""): + conn.execute( + "UPDATE agents SET config_json = ? WHERE agent_id = ?", + (encoded, agent_id), + ) + conn.commit() + finally: + conn.close() + + +def _persist_restored_workspace_paths(agent_repo: AgentRepo, paths: PathLayout) -> None: + """Rewrite restored agent configs so later reads stay inside ``paths.root``.""" + for row in agent_repo.list_all(): + dest = restored_workspace_dir(row.config_json, paths=paths, agent_id=row.agent_id) + try: + cfg = json.loads(row.config_json or "{}") + except json.JSONDecodeError: + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + cfg = confine_workspace_config(cfg, home=paths.root, agent_id=row.agent_id) + cfg["workspace_dir"] = str(dest) + agent_repo.update_config(row.agent_id, config_json=json.dumps(cfg)) + + +def _path_is_under(path: Path, home: Path) -> bool: + from octop.infra.agents.workspace_dir import path_is_under_home + + return path_is_under_home(path, home) + + def create_system_backup( *, paths: PathLayout, @@ -281,6 +333,7 @@ def create_system_backup( snapshot_sqlite_file(pool.path, db_dest) if not include_chats: strip_chat_tables_from_sqlite_file(db_dest) + _relativize_sqlite_workspace_paths(db_dest, paths.root) manifest_path = root / _MANIFEST_NAME manifest_path.write_text(manifest.to_json(), encoding="utf-8") @@ -594,6 +647,7 @@ def restore_system_backup( restored_workspaces = 0 prefix = f"{_WORKSPACES_DIR}/" agent_repo = AgentRepo(pool) + _persist_restored_workspace_paths(agent_repo, paths) workspace_by_agent: dict[str, Path] = {} system_path_by_agent: dict[str, str] = {} for rel, src_file in _iter_extracted_files(extracted, _WORKSPACES_DIR): @@ -606,7 +660,7 @@ def restore_system_backup( dest_root = workspace_by_agent.get(agent_id) if dest_root is None: agent_row = agent_repo.get(agent_id) - dest_root = workspace_dir_from_config_json( + dest_root = restored_workspace_dir( None if agent_row is None else agent_row.config_json, paths=paths, agent_id=agent_id, @@ -616,6 +670,12 @@ def restore_system_backup( _system_files_path_from_row(agent_row) if agent_row is not None else "" ) dest = dest_root / rest + if not _path_is_under(dest, paths.root): + raise OctopError( + ErrorCode.HOME_WRITE_REFUSED, + f"refusing to restore {dest} outside {paths.root}", + details={"path": str(dest), "home": str(paths.root)}, + ) if not manifest.includes_chats and is_chat_workspace_rel( Path(rest), system_files_path=system_path_by_agent.get(agent_id, ""), 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/src/octop/infra/errors.py b/src/octop/infra/errors.py index 23344cc8..1851b2f9 100644 --- a/src/octop/infra/errors.py +++ b/src/octop/infra/errors.py @@ -110,6 +110,9 @@ 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" + HOME_WRITE_REFUSED = "HOME_WRITE_REFUSED" + AGENT_SEED_EXISTS = "AGENT_SEED_EXISTS" _DEFAULT_STATUS: dict[ErrorCode, int] = { @@ -211,6 +214,9 @@ class ErrorCode(StrEnum): ErrorCode.WORKSPACE_ROOT_RESTRICTED: 400, 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, } @@ -260,3 +266,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/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/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/src/octop/infra/setup/service.py b/src/octop/infra/setup/service.py index a81abfee..aa0e61b2 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]: @@ -393,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" 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/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/src/octop/modules/org_os/managed_runtime.py b/src/octop/modules/org_os/managed_runtime.py index 541a069c..f66d11cf 100644 --- a/src/octop/modules/org_os/managed_runtime.py +++ b/src/octop/modules/org_os/managed_runtime.py @@ -194,13 +194,10 @@ async def start(self) -> None: port=port, ) env = await asyncio.to_thread(sidecar_launch_env, self.home) - env.update( - { - "FREEOS_ORG_INTEGRATED": "1", - "FREEOS_ORG_LOCAL_TEST": "1", - "HOST": "127.0.0.1", - } - ) + env["HOST"] = "127.0.0.1" + from octop.modules.org_os.sidecar_launch import apply_org_launch_flags # noqa: PLC0415 + + apply_org_launch_flags(env) log_path = self.home / "logs" / "organization-runtime.log" await asyncio.to_thread(log_path.parent.mkdir, parents=True, exist_ok=True) diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 4556b33a..a7c48dda 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -348,6 +348,33 @@ def sidecar_node_command(runtime: SidecarRuntime) -> str: return " ".join(sidecar_node_argv(runtime)) +_ENV_TRUTHY = {"1", "true", "yes", "on"} + + +def _env_truthy(source: dict[str, str], key: str) -> bool: + return (source.get(key) or "").strip().lower() in _ENV_TRUTHY + + +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 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 + env.pop("FREEOS_ORG_LOCAL_TEST", None) + + def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict[str, str]: secrets = load_or_create_sidecar_secrets(home) jwt = secrets["JWT_SECRET"] @@ -370,10 +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": bind_host, "DB_DIALECT": "sqlite", "DATABASE_PATH": str(sidecar_data_dir(home) / "xiongyuan.db"), "AIR_GAP_MODE": "true", @@ -386,13 +416,9 @@ def sidecar_launch_env(home: Path, *, dashboard_port: int | None = None) -> dict "FREEOS_HOME": str(home), "OCTOP_HOME": str(home), "FREEOS_ORG_SIDECAR_PORT": port, - # Independent openXYOS test environment: keep its own login and - # bootstrap demo@demo.com / user@demo.com. Restart/recover must - # not inherit a host INTEGRATED=1 without LOCAL_TEST (that skips seed). - "FREEOS_ORG_INTEGRATED": "1", - "FREEOS_ORG_LOCAL_TEST": "1", } ) + apply_org_launch_flags(env) return env 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_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 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/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/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() 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 diff --git a/tests/unit/backup/test_restore_home_confine.py b/tests/unit/backup/test_restore_home_confine.py new file mode 100644 index 00000000..db87a8d4 --- /dev/null +++ b/tests/unit/backup/test_restore_home_confine.py @@ -0,0 +1,132 @@ +"""Restore must not write agent workspaces outside ``--home``.""" + +from __future__ import annotations + +import hashlib +import io +import json +import sqlite3 +import tarfile +from pathlib import Path + +from octop.config import DatabaseConfig +from octop.infra.backup.system_archive import create_system_backup, restore_system_backup +from octop.infra.db.migrate import run_migrations +from octop.infra.db.pool import SqlitePool +from octop.infra.db.repos.agents import AgentRepo +from octop.infra.utils.paths import PathLayout + + +def _fingerprint(path: Path) -> tuple[str, int]: + stat = path.stat() + return hashlib.sha256(path.read_bytes()).hexdigest(), stat.st_mtime_ns + + +def _repack(archive: Path, edits: dict[str, bytes]) -> None: + members: dict[str, bytes] = {} + with tarfile.open(archive, mode="r:gz") as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + extracted = tf.extractfile(member) + assert extracted is not None + members[member.name] = extracted.read() + members.update(edits) + with tarfile.open(archive, mode="w:gz") as tf: + for name, blob in members.items(): + info = tarfile.TarInfo(name=name) + info.size = len(blob) + tf.addfile(info, io.BytesIO(blob)) + + +def test_restore_home_does_not_overwrite_live_absolute_workspace(tmp_path: Path) -> None: + source = PathLayout(tmp_path / "old-home") + source.root.mkdir() + live = source.root / "agents" / "main" + live.mkdir(parents=True) + soul = live / "SOUL.md" + soul.write_text("archived\n", encoding="utf-8") + pool = SqlitePool(source.db) + run_migrations(pool) + with pool.connect() as conn: + conn.execute( + "INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, ?)", + ("admin", "hash", "admin", 1), + ) + repo = AgentRepo(pool) + repo.create( + agent_id="main", + user_id=1, + name="Main", + config_json=json.dumps( + { + "workspace_dir": str(live.resolve()), + "backend": { + "type": "local_shell", + "root_dir": str(source.root.resolve()), + "virtual_mode": True, + }, + } + ), + ) + archive = tmp_path / "backup.tar.gz" + create_system_backup( + paths=source, + agent_rows=[repo.get("main")], + pool=pool, + db_config=DatabaseConfig(), + dest=archive, + include_workspaces=True, + include_config=False, + ) + pool.close() + + with tarfile.open(archive, mode="r:gz") as tf: + raw_db = tf.extractfile("db/octop.db") + assert raw_db is not None + db_bytes = raw_db.read() + # Check the packed database records a home-relative workspace path. + disk = tmp_path / "snap.db" + disk.write_bytes(db_bytes) + recorded = sqlite3.connect(disk).execute("SELECT config_json FROM agents").fetchone() + assert recorded is not None + recorded_cfg = json.loads(recorded[0]) + assert recorded_cfg["workspace_dir"] == "agents/main" + assert not Path(recorded_cfg["workspace_dir"]).is_absolute() + + # Old archives stored the absolute path. Put that back and restore elsewhere. + old = json.dumps( + { + "workspace_dir": str(live.resolve()), + "backend": { + "type": "local_shell", + "root_dir": str(source.root.resolve()), + "virtual_mode": True, + }, + } + ) + conn = sqlite3.connect(disk) + conn.execute("UPDATE agents SET config_json = ?", (old,)) + conn.commit() + conn.close() + _repack(archive, {"db/octop.db": disk.read_bytes()}) + + soul.write_text("live-do-not-touch\n", encoding="utf-8") + before = _fingerprint(soul) + + target = PathLayout(tmp_path / "new-home") + target.root.mkdir() + target_pool = SqlitePool(target.db) + run_migrations(target_pool) + restore_system_backup( + archive, + paths=target, + pool=target_pool, + db_config=DatabaseConfig(), + restore_config=False, + ) + assert _fingerprint(soul) == before + assert (target.root / "agents" / "main" / "SOUL.md").read_text(encoding="utf-8") == "archived\n" + restored = json.loads(AgentRepo(target_pool).get("main").config_json or "{}") + assert Path(restored["workspace_dir"]).resolve().is_relative_to(target.root.resolve()) + target_pool.close() 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/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/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/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/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index 01d16323..2fe00491 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -159,6 +159,9 @@ 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 "FREEOS_ORG_DEV_BOOTSTRAP" in text + assert "$env:FREEOS_ORG_LOCAL_TEST = '1'" not in text.split("FREEOS_ORG_DEV_BOOTSTRAP", 1)[0] assert "http://localhost:3780" in origins for origin in origins: parsed = urlparse(origin) 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: 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 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_bind_loopback.py b/tests/unit/test_bind_loopback.py new file mode 100644 index 00000000..73f30966 --- /dev/null +++ b/tests/unit/test_bind_loopback.py @@ -0,0 +1,25 @@ +"""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_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_local_probe.py b/tests/unit/test_local_probe.py index e2be7bdb..4f6f30df 100644 --- a/tests/unit/test_local_probe.py +++ b/tests/unit/test_local_probe.py @@ -1,4 +1,11 @@ -from octop.infra.agents.providers.local_probe import probe_local_models +import urllib.error + +import pytest + +from octop.infra.agents.providers.local_probe import ( + http_endpoint_reachable, + probe_local_models, +) def test_probe_local_models_shape() -> None: @@ -13,3 +20,32 @@ def test_probe_local_models_shape() -> None: assert "ram_gb" in hw assert "ollama_installed" in hw assert "ollama_reachable" in hw + assert payload["reachable_provider"] is bool( + hw["ollama_reachable"] or hw.get("llamacpp_reachable") + ) + + +def test_http_endpoint_reachable_treats_auth_failure_as_up( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _denied(*_args: object, **_kwargs: object) -> None: + raise urllib.error.HTTPError("http://127.0.0.1:11434/models", 401, "no", hdrs=None, fp=None) + + monkeypatch.setattr( + "octop.infra.agents.providers.local_probe.urllib.request.urlopen", + _denied, + ) + assert http_endpoint_reachable("http://127.0.0.1:11434") is True + + +def test_http_endpoint_reachable_rejects_connection_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _down(*_args: object, **_kwargs: object) -> None: + raise urllib.error.URLError("refused") + + monkeypatch.setattr( + "octop.infra.agents.providers.local_probe.urllib.request.urlopen", + _down, + ) + assert http_endpoint_reachable("http://127.0.0.1:9") is False 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 == [] 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 diff --git a/tests/unit/test_release_download_links.py b/tests/unit/test_release_download_links.py index 53ccc266..1fd3b3ae 100644 --- a/tests/unit/test_release_download_links.py +++ b/tests/unit/test_release_download_links.py @@ -38,7 +38,11 @@ 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 "pip install" not 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 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_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..0e28ed24 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -304,9 +304,44 @@ def test_sidecar_launch_env_writes_secrets(tmp_path: Path) -> None: assert "FREEOS_INGEST_TOKEN=" in secrets assert env["FREEOS_INGEST_TOKEN"] assert env["FREEOS_ORG_INTEGRATED"] == "1" + assert "FREEOS_ORG_LOCAL_TEST" not in env + assert env["HOST"] == "127.0.0.1" + + +def test_sidecar_launch_env_local_test_requires_explicit_switch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> 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_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: + 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: diff --git a/tests/unit/test_upgrade_backup.py b/tests/unit/test_upgrade_backup.py new file mode 100644 index 00000000..ac299921 --- /dev/null +++ b/tests/unit/test_upgrade_backup.py @@ -0,0 +1,143 @@ +"""Upgrades must take a full backup and record its sha256 before replacing bits.""" + +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: + text = (REPO / "scripts" / "install.sh").read_text(encoding="utf-8") + backup = text.index("_backup_before_upgrade") + call = text.index("\n_backup_before_upgrade\n") + package = text.index('uv pip install "${SOURCE_DIR}${EXTRAS_SUFFIX}"', call) + assert "backup create" in text[backup:package] + assert "--include-chats" in text[backup:package] + assert "--home" in text[backup:package] + assert "Upgrade aborted: backup failed" in text + assert "sha256" in text[backup:package] + + +def test_install_ps1_backs_up_before_venv_clear() -> None: + text = (REPO / "scripts" / "install.ps1").read_text(encoding="utf-8") + backup = text.index("backup create") + clear = text.index("uv venv $OctopVenv") + assert backup < clear + assert "--include-chats" in text + assert "Upgrade aborted: backup failed" in text + 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" + 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 + + +@posix_only +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 + assert '"--include-chats"' in text + assert '"--home"' in text + assert "sha256File" in text + assert ".sha256" in text + assert "sqlite3" not in text + assert "pre-upgrade backup failed" in text 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 diff --git a/tests/unit/test_write_sha256sums.py b/tests/unit/test_write_sha256sums.py new file mode 100644 index 00000000..eef08c07 --- /dev/null +++ b/tests/unit/test_write_sha256sums.py @@ -0,0 +1,114 @@ +"""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"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(): + (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-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 "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: + 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", + "Octop-fnos-docker-0.1.0rc1.fpk", + ], + ) + 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: + (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") + 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 diff --git a/uv.lock b/uv.lock index 75ee7d32..d66146e5 100644 --- a/uv.lock +++ b/uv.lock @@ -2502,8 +2502,8 @@ wheels = [ ] [[package]] -name = "octop" -version = "0.0.10" +name = "freeos" +version = "0.1.0rc1" source = { editable = "." } dependencies = [ { name = "acme" },