diff --git a/CHANGELOG.md b/CHANGELOG.md index 4043066e..a8e0b3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- Windows 安装成功后删除 `$INSTDIR\openxyos-runtime` 目录和 `openxyos-runtime.zip`(以及 `%LOCALAPPDATA%\FreeOS` 下同名暂存)。预配失败时保留这些文件以便排查。最终工作目录 `$INSTDIR\openxyos` 与 `%LOCALAPPDATA%\FreeOS\openxyos` 不受影响。 - ima 知识库挂接对齐官方 Agent Interface(https://ima.qq.com/agent-interface,落地页现发 ima-skill 1.1.10):用 Client ID / API Key 连接后,按 `search_knowledge_base` + `get_knowledge_base` 选知识库,按 `get_knowledge_list` / `search_knowledge` 自选文档;文件夹 ID 用接口返回的 `media_id`(不自造 `folder_` 前缀),选择会写入挂接。 - 模型页注册的本机 Ollama / GGUF 现在会出现在对话区模型选择器里,并走本机 `127.0.0.1:11434` 调用。注册会补齐 Ollama 供应商默认值、打开服务开关、热加载对话列表;Ollama 未启动时给出可操作错误,而不是静默失败。 - 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。 diff --git a/desktop/README.md b/desktop/README.md index aa6e65f7..d67761b3 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -26,7 +26,8 @@ Same precedence as the FreeOS CLI/server: else `{home}/openxyos`. During Setup the FreeOS installer `nsExec`s a shipped **provisioner subprocess** (`provision-openxyos.ps1`) that expands `openxyos-runtime.zip` with `tar.exe` into that live workdir (and keeps - `$INSTDIR\openxyos` / `$INSTDIR\openxyos-runtime` as backups), starts FE/BE + `$INSTDIR\openxyos` as a sealed copy; the `openxyos-runtime` zip and folder + are deleted after a successful provision), starts FE/BE at medium integrity (Node inherits `CORS_ORIGIN` / `NODE_ENV`; stdout/stderr go to `start.log`), and writes `.install-ready` only after `http://127.0.0.1:3780/api/health/livez` is healthy. If Node dies before @@ -54,7 +55,11 @@ a child process** and waits for exit 0. The child extracts with Windows `%LOCALAPPDATA%\FreeOS\openxyos`, starts Node at medium integrity (IShellDispatch2 — never High-IL Node from the elevated installer), waits for livez, and writes `.install-ready` only when healthy. `$INSTDIR\openxyos` -and `$INSTDIR\openxyos-runtime` are sealed backups. A README-only tree or a +is the sealed install copy. After success the provisioner removes +`$INSTDIR\openxyos-runtime.zip` (and similarly named archives) plus the +`$INSTDIR\openxyos-runtime` folder, and a sibling `openxyos-runtime` under +`%LOCALAPPDATA%\FreeOS` if one was staged there. A failed provision leaves +those artifacts for debugging. A README-only tree or a failed health check is a provision failure, not a skipped success. Setup does **not** register HKCU Run or a logon scheduled task. diff --git a/desktop/src/build/windows/nsis/provision-openxyos.ps1 b/desktop/src/build/windows/nsis/provision-openxyos.ps1 index 9d20bba9..ff152b06 100644 --- a/desktop/src/build/windows/nsis/provision-openxyos.ps1 +++ b/desktop/src/build/windows/nsis/provision-openxyos.ps1 @@ -1,10 +1,13 @@ # Install-time openXYOS provisioner (child process of the FreeOS NSIS Setup). # Extract + normalize + start once at medium IL + wait livez + write .install-ready. # Exit 0 only when http://127.0.0.1:3780/api/health/livez is healthy. +# On success, deletes $InstallDir\openxyos-runtime (folder + archive) and a +# sibling openxyos-runtime under the live parent. Never deletes $LiveDir or +# $InstallDir\openxyos. Failed runs leave staging in place for debugging. # Does NOT register HKCU Run / scheduled-task logon autostart. # # Exit codes (NSIS maps these to localized MessageBox text): -# 0 healthy (.install-ready written) +# 0 healthy (.install-ready written; openxyos-runtime staging removed) # 2 zip missing # 3 tar extract into live dir failed # 5 tar.exe missing @@ -300,6 +303,83 @@ function Invoke-TarExtract { return ($LASTEXITCODE -eq 0) } +function Test-SamePath { + param([string]$Left, [string]$Right) + if (-not $Left -or -not $Right) { return $false } + try { + $a = [IO.Path]::GetFullPath($Left) + $b = [IO.Path]::GetFullPath($Right) + } catch { + return $false + } + return $a.Equals($b, [StringComparison]::OrdinalIgnoreCase) +} + +function Test-PathUnderRoot { + param([string]$Path, [string]$Root) + if (-not $Path -or -not $Root) { return $false } + try { + $full = [IO.Path]::GetFullPath($Path) + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + } catch { + return $false + } + if ($full.Equals($rootFull, [StringComparison]::OrdinalIgnoreCase)) { return $true } + $prefix = $rootFull + [IO.Path]::DirectorySeparatorChar + return $full.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) +} + +function Remove-OpenXYOSStaging { + $keepLive = $LiveDir + $keepInstall = Join-Path $InstallDir 'openxyos' + $stageDirs = New-Object System.Collections.Generic.List[string] + $stageDirs.Add((Join-Path $InstallDir 'openxyos-runtime')) + $liveParent = '' + if ($LiveDir) { + $liveParent = Split-Path -Parent $LiveDir + if ($liveParent) { + $stageDirs.Add((Join-Path $liveParent 'openxyos-runtime')) + } + } + foreach ($dir in $stageDirs) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + if ((Test-SamePath $dir $keepLive) -or (Test-SamePath $dir $keepInstall)) { + Write-ProvLog "Skipping staging cleanup of protected path $dir" + continue + } + Write-ProvLog "Removing staging directory $dir" + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } + $archiveRoots = New-Object System.Collections.Generic.List[string] + if ($InstallDir) { $archiveRoots.Add($InstallDir) } + if ($liveParent) { $archiveRoots.Add($liveParent) } + foreach ($root in $archiveRoots) { + if (-not (Test-Path -LiteralPath $root)) { continue } + Get-ChildItem -LiteralPath $root -File -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.Name -notmatch '(?i)^openxyos-runtime(\.(zip|7z|tgz|tar|tar\.gz))?$') { return } + Write-ProvLog "Removing staging archive $($_.FullName)" + Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue + } + } + if ($ZipPath -and (Test-Path -LiteralPath $ZipPath)) { + $zipIsStaging = (Test-PathUnderRoot $ZipPath $InstallDir) -or ( + $liveParent -and (Test-PathUnderRoot $ZipPath $liveParent) + ) + if ($zipIsStaging) { + Write-ProvLog "Removing staging zip $ZipPath" + Remove-Item -LiteralPath $ZipPath -Force -ErrorAction SilentlyContinue + } + } +} + +function Complete-OpenXYOSSuccess { + if (-not (Write-InstallReady $LiveDir)) { + exit 13 + } + Remove-OpenXYOSStaging + exit 0 +} + # --- main --- New-Item -ItemType Directory -Force -Path $LiveDir | Out-Null Write-ProvLog "provision-openxyos start zip=$ZipPath install=$InstallDir live=$LiveDir" @@ -307,8 +387,7 @@ Write-ProvLog "provision-openxyos start zip=$ZipPath install=$InstallDir live=$L $marker = Join-Path $LiveDir '.install-ready' if ((Test-OpenXYOSLayout $LiveDir) -and (Test-OpenXYOSLivez)) { Write-ProvLog 'Already extracted and livez healthy (idempotent)' - if (Write-InstallReady $LiveDir) { exit 0 } - exit 13 + Complete-OpenXYOSSuccess } if (Test-Path -LiteralPath $marker) { Write-ProvLog 'Removing stale .install-ready (livez not healthy)' @@ -392,8 +471,7 @@ if (-not (Test-OpenXYOSCompiledSql $LiveDir)) { $readme = @( 'FreeOS local openXYOS environment' "Live workdir (writable): $LiveDir" - "Install backup: $backupOpen" - "Sealed backup: $backupRuntime" + "Install copy: $backupOpen" 'URL: http://127.0.0.1:3780' ) -join "`r`n" Set-Content -LiteralPath (Join-Path $LiveDir 'README.txt') -Value $readme -Encoding ASCII @@ -411,8 +489,7 @@ Install-StartHelpers $LiveDir if (Test-OpenXYOSLivez) { Write-ProvLog 'livez already healthy after extract' - if (Write-InstallReady $LiveDir) { exit 0 } - exit 13 + Complete-OpenXYOSSuccess } if (-not (Start-OpenXYOSUnelevated $LiveDir)) { @@ -422,8 +499,7 @@ if (-not (Start-OpenXYOSUnelevated $LiveDir)) { } if (Wait-OpenXYOSLivez $LivezTimeoutSec) { - if (Write-InstallReady $LiveDir) { exit 0 } - exit 13 + Complete-OpenXYOSSuccess } Write-ProvLog "livez not ready after ${LivezTimeoutSec}s; re-invoking start once" @@ -433,8 +509,7 @@ if (-not (Start-OpenXYOSUnelevated $LiveDir)) { exit 10 } if (Wait-OpenXYOSLivez $RetryTimeoutSec) { - if (Write-InstallReady $LiveDir) { exit 0 } - exit 13 + Complete-OpenXYOSSuccess } Write-ProvLog 'livez timeout after start retry — see this log and start.log (not assumed to be port 3780 in use)' diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 01beae11..4f0560c5 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -218,6 +218,10 @@ def test_nsis_runs_openxyos_provisioner_subprocess() -> None: assert "nsExec::ExecToLog $R4" in provision assert r"$INSTDIR\openxyos-runtime.zip" in provision assert r"$R6\FreeOS\openxyos" in provision + # Cleanup after success lives in the provisioner, not NSIS (failed + # provision must keep the zip / openxyos-runtime folder for debug). + assert 'Delete "$INSTDIR\\openxyos-runtime.zip"' not in provision + assert 'RMDir /r "$INSTDIR\\openxyos-runtime"' not in provision assert "Pop $0" in provision assert "Abort" in provision assert ".install-ready" in provision diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index 538a20d5..00d3b7ff 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -110,11 +110,43 @@ def test_provisioner_fail_fast_when_node_dies() -> None: def test_provisioner_writes_marker_only_when_healthy() -> None: text = PROVISION_PS1.read_text(encoding="utf-8") write_fn = text.index("function Write-InstallReady") - first_write = text.index("if (Write-InstallReady $LiveDir) { exit 0 }") - assert write_fn < first_write + complete_fn = text.index("function Complete-OpenXYOSSuccess") + first_complete = text.index( + "Complete-OpenXYOSSuccess", complete_fn + len("function Complete-OpenXYOSSuccess") + ) + assert write_fn < complete_fn < first_complete assert "Test-OpenXYOSLivez" in text assert ".install-ready" in text assert write_fn < text.index("status=healthy") + assert "if (Write-InstallReady $LiveDir) { exit 0 }" not in text + + +def test_provisioner_removes_staging_only_after_success() -> None: + """Zip + openxyos-runtime folder go away only after .install-ready.""" + text = PROVISION_PS1.read_text(encoding="utf-8") + clean_fn = text.index("function Remove-OpenXYOSStaging") + complete_fn = text.index("function Complete-OpenXYOSSuccess") + main = text.index("# --- main ---") + assert clean_fn < complete_fn < main + complete_body = text[complete_fn:main] + assert complete_body.index("Write-InstallReady") < complete_body.index("Remove-OpenXYOSStaging") + assert complete_body.index("Remove-OpenXYOSStaging") < complete_body.index("exit 0") + assert "exit 13" in complete_body + clean_body = text[clean_fn:complete_fn] + assert "Join-Path $InstallDir 'openxyos-runtime'" in clean_body + assert "Join-Path $liveParent 'openxyos-runtime'" in clean_body + assert "openxyos-runtime" in clean_body + assert "SilentlyContinue" in clean_body + assert "Join-Path $InstallDir 'openxyos'" in clean_body + assert "Skipping staging cleanup of protected path" in clean_body + # Failure exits must not call cleanup (keep zip/folder for debug). + for code in ("exit 2", "exit 3", "exit 5", "exit 6", "exit 7", "exit 10", "exit 12"): + idx = text.index(code) + window = text[max(0, idx - 120) : idx] + assert "Remove-OpenXYOSStaging" not in window, code + assert "Complete-OpenXYOSSuccess" not in window, code + assert text.count("Complete-OpenXYOSSuccess") >= 5 + assert "Sealed backup:" not in text def test_wrappers_have_no_goto_labels() -> None: