# 云鸢(Kite)daemon 一键接入脚本(Windows / PowerShell)。 # # 本脚本由 relay 服务端模板渲染:RELAY_WS / 下载基址 / 版本 pin 由 relay 据自身域名插入。 # `irm https://kite.lumenlan.com/install.ps1 | iex` 自动产出对应环境脚本,零脚本端 if。 # # 装好后 daemon 首启走「生态设备授权」:弹浏览器登录灵澜账号,或无浏览器时显示机器码网页授权, # 拿 per-machine token 存 Windows 凭据管理器(keyring)——无需手工粘 token。 # # plan 05 WS-2 形态:daemon **不再装 SCM 系统服务**(LocalSystem 与用户 PATH/profile/凭据管理器/ # 桌面四重错配,找不到 claude、读不到登录态、弹不了浏览器)。改用 `schtasks /SC ONLOGON` 注册 # **当前登录用户**的登录自启任务(daemon service install 内部实现,见 service_windows.go),进程继承 # 用户环境——故**无需管理员**、不再弹 UAC。 # # 幂等:$ErrorActionPreference=Stop + 每步可重入(二进制覆盖、任务无条件重建 + 重启)。 $ErrorActionPreference = "Stop" # ── 模板注入(relay 渲染,禁脚本端硬编码域名 / 下载源)── $RelayWS = "wss://kite.lumenlan.com/v1/daemon/connect" # wss:///v1/daemon/connect $AccountBase = "https://account.lumenlan.com" # 灵澜账号 base(device flow),空=daemon 用内置默认 $ReleaseBase = "https://kite.lumenlan.com/dl" # daemon 二进制下载基址(由 relay env 注入) $Version = "v0.14.7" # 版本 pin(如 v0.1.0),空=latest # 安装/配置目录默认用**用户可写**目录(用户态、无管理员):LOCALAPPDATA\Kite。 # 旧版默认 ProgramFiles/ProgramData 需管理员写——改用户态后不再用(除非显式覆盖 env)。 # [plan 16 WS-5] 自更新防变砖布局:launcher(永不自更新)装 InstallDir;daemon payload(自更新对象) # 落 PayloadDir。Windows 下两者都在 LOCALAPPDATA(本就用户可写),PayloadDir 用子目录 bin 与 launcher 分开。 $BinName = "kite-daemon.exe" $LauncherName = "kite-launcher.exe" $InstallDir = if ($env:KITE_INSTALL_DIR) { $env:KITE_INSTALL_DIR } else { "$env:LOCALAPPDATA\Kite" } $PayloadDir = if ($env:KITE_DAEMON_PAYLOAD_DIR) { $env:KITE_DAEMON_PAYLOAD_DIR } else { "$env:LOCALAPPDATA\Kite\bin" } $ConfDir = if ($env:KITE_CONF_DIR) { $env:KITE_CONF_DIR } else { "$env:LOCALAPPDATA\Kite" } $ConfFile = Join-Path $ConfDir "daemon.env" $AuthFile = Join-Path $ConfDir "device-auth.txt" function Info($m) { Write-Host "▸ $m" -ForegroundColor Cyan } function Die($m) { Write-Host "✗ $m" -ForegroundColor Red; exit 1 } # ── 1. 检测 arch → 拼资产名 ── $arch = $env:PROCESSOR_ARCHITECTURE switch ($arch) { "AMD64" { $GoArch = "amd64" } "ARM64" { $GoArch = "arm64" } default { Die "不支持的架构:$arch(仅 amd64 / arm64)" } } if ([string]::IsNullOrWhiteSpace($ReleaseBase)) { Die "relay 未配置 daemon 下载基址(KITE_RELAY_DAEMON_RELEASE_BASE)——联系管理员。" } # 资产名固定不带版本号(版本由 Release tag / latest 别名经 URL 路径体现,不进文件名—— # 与 build-daemon.sh / release.yml 同款命名;带版本会让 latest/download 别名永远命不中)。 # AssetUrl <资产基名> → 完整下载 URL(按 Version pin / latest 分支)。 function AssetUrl($name) { $asset = "$name-windows-$GoArch.exe" if ([string]::IsNullOrWhiteSpace($Version)) { return "$($ReleaseBase.TrimEnd('/'))/latest/download/$asset" } else { return "$($ReleaseBase.TrimEnd('/'))/download/$Version/$asset" } } $DaemonUrl = AssetUrl "kite-daemon" $LauncherUrl = AssetUrl "kite-launcher" # ── 清理旧版 SCM + 停掉运行中的 launcher / daemon(重装 / 升级必经)── # plan 05 以前装过的 kardianos Windows 服务运行于 LocalSystem。它不读当前用户的 PATH/keyring, # 也不会被用户态 schtasks/HKCU 卸载路径清掉;若标准用户无权删除,必须明确失败并要求管理员处理, # 不能 SilentlyContinue 后留下一个仍连旧账号的残余 daemon。 $legacyService = Get-Service -Name "kite-daemon" -ErrorAction SilentlyContinue if ($null -ne $legacyService) { Info "检测到旧版 Windows 系统服务 kite-daemon,正在迁移清理" try { if ($legacyService.Status -ne "Stopped") { Stop-Service -Name "kite-daemon" -Force -ErrorAction Stop $legacyService.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } # 释放 Get-Service 持有的 SCM handle,避免由安装器自己延长 marked-for-deletion 窗口。 $legacyService.Dispose() $legacyService = $null & sc.exe delete "kite-daemon" | Out-Null # 1060=服务已不存在;1072=服务已进入删除终态。两者都可幂等继续;access denied 等仍 fail closed。 if ($LASTEXITCODE -notin @(0, 1060, 1072)) { throw "sc.exe delete 失败(exit=$LASTEXITCODE)" } } catch { Die "检测到旧版系统服务但无法清理。请以管理员身份运行:sc.exe stop kite-daemon;sc.exe delete kite-daemon,然后重新安装。" } } # Windows 锁住运行中的 .exe:不先停,下面 Move-Item 覆盖 kite-launcher.exe / kite-daemon.exe 会报 # 「当文件已存在时,无法创建该文件」(MoveFileInfoItemIOError)。停掉以释放文件锁;§5 service install # 末尾会把 launcher 重新拉起。launcher 停了就不会在覆盖期间又把旧 daemon 拉起来锁住文件。 Info "停止运行中的 kite-launcher / kite-daemon(释放文件锁,便于覆盖二进制)" $oldProcesses = @(Get-Process -Name kite-launcher, kite-daemon -ErrorAction SilentlyContinue) if ($oldProcesses.Count -gt 0) { $oldProcesses | Stop-Process -Force -ErrorAction SilentlyContinue foreach ($process in $oldProcesses) { try { [void]$process.WaitForExit(10000) } catch { } } } $residualProcesses = @(Get-Process -Name kite-launcher, kite-daemon -ErrorAction SilentlyContinue) if ($residualProcesses.Count -gt 0) { Die "仍有残余 kite 进程无法停止;请结束 kite-launcher.exe / kite-daemon.exe 后重新安装。" } # 必须在旧进程停止后清:旧 daemon 可能刚写出仍有效的 device code。若把该文件带到本轮,安装器会 # 展示一个创建者已经被杀掉的旧码——批准虽返回 200,却再也无人 poll/enroll。 if (Test-Path $AuthFile) { try { Remove-Item -Force $AuthFile -ErrorAction Stop } catch { Die "无法删除旧设备授权文件 $AuthFile;请关闭占用它的进程后重试。" } } $EnrollmentStartedUtc = [DateTime]::UtcNow function Download($url, $dest) { $tmp = "$dest.new" try { Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing } catch { Die "下载失败:$url($($_.Exception.Message))" } # Windows 不能覆盖被锁/运行中的 .exe:Move-Item -Force 直接覆盖现存 .exe 会偶发崩「当文件已存在时, # 无法创建该文件」(MoveFileInfoItemIOError)——-Force 的「删除-再移动」遇文件锁 / 句柄未及时释放即失败, # 即便上面 §「停进程」已 Stop-Process(700ms 未必够内核释放映像句柄)。但 Windows **允许 rename 运行中/ # 锁定的 exe**(移动目录项、进程映像句柄仍有效,Chrome 等自更新器同款)。故覆盖前先把现存目标 rename # 走腾空目标名,再把新文件移入——彻底绕开「覆盖被锁文件」这条失败路径。 if (Test-Path $dest) { try { Move-Item -Force $dest "$dest.old" } # 复用同一 .old(已停进程时未占用,-Force 可替换) catch { Move-Item -Force $dest "$dest.$(Get-Date -Format yyyyMMddHHmmssfff).old" } # .old 仍被占 → 唯一名兜底 } Move-Item $tmp $dest # 落盘后再核一次文件还在不在([v0.11.1])。安全软件可能在**写完之后**才把它隔离删掉—— # 那时 Invoke-WebRequest 与 Move-Item 都已成功返回,脚本会带着一个并不存在的文件继续往下走, # 最后倒在某个八竿子打不着的地方(「找不到指定的文件」),没人看得出真凶。 # 与其如此,不如在这里当场停下并说清楚是谁干的、该做什么。 if (-not (Test-Path $dest)) { Die ("刚下载好的文件又不见了:$dest`n" + " 这几乎总是安全软件把它隔离删除了(云鸢的自动更新会写出新程序并立刻运行," + "行为上像投毒器,容易被误判)。`n" + " 请以管理员身份运行一次:Add-MpPreference -ExclusionPath '$InstallDir'`n" + " 然后重新执行本安装脚本。") } } # ── 1.9 给安装目录申请一条安全软件排除([v0.11.1],2026-09-07 真机实撞)── # # 为什么要有这一步:自更新会「写出一个新的 exe 并立刻运行它、随即联网」,这正是投毒器的典型 # 行为形状。Windows Defender 的行为检测据此把刚落盘的 kite-daemon.exe 判成 # Behavior:Win32/Winwebsec 隔离删除了——新版并没有崩、甚至已经正常连上过服务端, # 但文件没了,只能回滚,那台机器就此永久停在旧版本上。装机时**下载之前**先要一条排除, # 既保住这一次安装,也保住此后每一次自动更新。 # # 四条纪律: # ① 幂等——先看在不在列表里,已在就什么都不做。重装不会重复添加;自动更新根本不跑本脚本, # 所以也不存在「每次更新加一次」。 # ② 只为这一条命令提权,不提权整个安装——本脚本按设计是用户态、不弹 UAC(daemon 要继承 # 用户环境才找得到 claude、读得到登录态)。已经是管理员就直接加、不弹窗;不是则只弹一次 # UAC 去跑那一条 Add-MpPreference,跑完仍回到用户态继续装。 # ③ 不看退出码看事实——提权那一路是另一个进程,回来后重新读一遍排除列表确认真加上了, # 不拿「进程退出码 0」当成事已办成。 # ④ 不阻断——用户点「否」、没有 Defender、装的是第三方杀软,都是常态不是错误: # 如实说明并给出可照做的那一条命令,安装照常继续。 function Get-KiteExclusions { try { return @((Get-MpPreference -ErrorAction Stop).ExclusionPath) } catch { return $null } } function Add-KiteAVExclusion($TargetDir) { $existing = Get-KiteExclusions if ($null -eq $existing) { return "unavailable" } if ($existing -contains $TargetDir) { return "already" } $isAdmin = ([Security.Principal.WindowsPrincipal] ` [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) if ($isAdmin) { try { Add-MpPreference -ExclusionPath $TargetDir -ErrorAction Stop; return "added" } catch { return "failed" } } # 单引号内的单引号要翻倍,否则带 ' 的路径会把提权那条命令截断。 $quoted = "'" + ($TargetDir -replace "'", "''") + "'" try { Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList ` "-NoProfile", "-NonInteractive", "-Command", "Add-MpPreference -ExclusionPath $quoted" | Out-Null } catch { return "declined" # 用户在 UAC 上点了「否」,或本机不允许提权 } # 不信退出码,回来重新读一遍列表。 $after = Get-KiteExclusions if ($null -ne $after -and ($after -contains $TargetDir)) { return "added-elevated" } return "failed" } function Show-KiteAVHint($TargetDir) { Write-Host "▸ 没能自动添加安全软件排除(安装继续)。" -ForegroundColor Yellow Write-Host " 建议以管理员身份运行一次下面这条命令,否则安装或此后的自动更新可能被安全软件拦下来:" -ForegroundColor Yellow Write-Host " Add-MpPreference -ExclusionPath '$TargetDir'" -ForegroundColor Yellow } switch (Add-KiteAVExclusion $InstallDir) { "already" { Info "安全软件排除已在,跳过" } "added" { Info "已添加安全软件排除(避免安装与自动更新时程序被误删)" } "added-elevated"{ Info "已添加安全软件排除(避免安装与自动更新时程序被误删)" } "unavailable" { Info "本机没有可配置的 Windows 安全中心排除项,跳过这一步" } default { Show-KiteAVHint $InstallDir } } # ── 2. 下载二进制(先下到 .new 再覆盖,重入安全)── # 2a. launcher → InstallDir([plan 16 WS-5] 防变砖兜底,装一次、永不自更新)。 New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $LauncherPath = Join-Path $InstallDir $LauncherName Info "下载 launcher:$LauncherUrl" Download $LauncherUrl $LauncherPath Info "安装 launcher 到 $LauncherPath" # 2b. daemon payload → PayloadDir(自更新对象)。 New-Item -ItemType Directory -Force -Path $PayloadDir | Out-Null $DaemonBin = Join-Path $PayloadDir $BinName Info "下载 daemon:$DaemonUrl" Download $DaemonUrl $DaemonBin Info "安装 daemon payload 到 $DaemonBin(自更新对象)" # 现存机迁移([plan 16 WS-5]):旧布局把 daemon 装在 InstallDir 且自启直指它。新布局自启=launcher、 # daemon 在 PayloadDir。旧 InstallDir\kite-daemon.exe 留着无害,best-effort 清掉免版本困惑。 $OldDaemon = Join-Path $InstallDir $BinName if (Test-Path $OldDaemon) { Info "迁移:移除旧位 $OldDaemon(改由 PayloadDir 管理 + launcher supervise)" Remove-Item -Force $OldDaemon -ErrorAction SilentlyContinue } # ── 3. 写配置(relay 地址来自模板;token 不落盘,走设备授权存凭据管理器)── Info "写配置 $ConfFile" New-Item -ItemType Directory -Force -Path $ConfDir | Out-Null $lines = @( "# 云鸢 daemon 配置(install.ps1 生成;token 不在此文件——走设备授权存凭据管理器)。", "KITE_DAEMON_RELAY_URL=$RelayWS" ) if (-not [string]::IsNullOrWhiteSpace($AccountBase)) { $lines += "KITE_DAEMON_ACCOUNT_BASE_URL=$AccountBase" } # [plan08 WS-2] 默认空:探测恒为 kite 支持全集(含 copilot/qoder/trae),允许范围由网页控制。 # 旧默认 claude,codex,opencode 会把本机锁死成三个、装了 copilot 也探不到。仅高安部署显式收窄。 $agents = if ($env:KITE_DAEMON_AGENTS) { $env:KITE_DAEMON_AGENTS } else { "" } $lines += "KITE_DAEMON_AGENTS=$agents" # 合并写入([v0.5.3 plan09] 重装不覆写用户自定义行):既有 conf 里非管理键(如 HTTPS_PROXY / # KITE_DAEMON_USE_SYSTEM_PROXY / ALLOW_SHELL)原样保留——走代理出网的机器重装后不再失联。 # AUTOSTART 是已退役的旧受管键:继续列在过滤式里,只为重装时清掉存量 `=true`,绝不保留回来。 if (Test-Path $ConfFile) { $preserved = @(Get-Content $ConfFile | Where-Object { $_ -notmatch '^KITE_DAEMON_(RELAY_URL|ACCOUNT_BASE_URL|AGENTS|AUTOSTART)=' -and $_ -notmatch '^# 云鸢 daemon 配置' -and $_ -notmatch '^# ── 以下为用户自定义行' -and $_.Trim() -ne '' }) if ($preserved.Count -gt 0) { $lines += '# ── 以下为用户自定义行(重装保留)──' $lines += $preserved } } Set-Content -Path $ConfFile -Value $lines -Encoding utf8 # ── 4. 探测并引导安装 agent CLI(只展示官网当前原生安装入口,不静默执行第三方脚本)── Info "探测本机编码 agent CLI" if (Get-Command claude -ErrorAction SilentlyContinue) { Write-Host " ✓ claude 已安装:$((Get-Command claude).Source)" -ForegroundColor Green } else { Write-Host " · 未探测到 claude(Claude Code CLI)。" -ForegroundColor Yellow Write-Host ' 官方安装:irm https://claude.ai/install.ps1 | iex' -ForegroundColor Cyan } # codex / opencode:官方安装命令属 plan §7 调研项,未定前不硬写安装命令(红线:不确定不硬写)。 # TODO(plan 05 §7 调研): 确认 codex / opencode 官方安装来源 + node 依赖后,补探测+可选安装引导。 if (Get-Command codex -ErrorAction SilentlyContinue) { Write-Host " ✓ codex 已安装:$((Get-Command codex).Source)" -ForegroundColor Green } if (Get-Command opencode -ErrorAction SilentlyContinue) { Write-Host " ✓ opencode 已安装:$((Get-Command opencode).Source)" -ForegroundColor Green } # [v0.6.11 plan02] Kimi Code 官方原生安装器;只展示,不由 Kite 静默执行第三方脚本。 if (Get-Command kimi -ErrorAction SilentlyContinue) { Write-Host " ✓ kimi 已安装:$((Get-Command kimi).Source)" -ForegroundColor Green } else { Write-Host ' · 未探测到 kimi(Kimi Code)。官方安装:' -ForegroundColor Yellow Write-Host ' irm https://code.kimi.com/kimi-code/install.ps1 | iex' -ForegroundColor Cyan } if (Get-Command codebuddy -ErrorAction SilentlyContinue) { Write-Host " ✓ codebuddy 已安装:$((Get-Command codebuddy).Source)" -ForegroundColor Green } elseif (Get-Command cbc -ErrorAction SilentlyContinue) { Write-Host " ✓ codebuddy 已安装(cbc):$((Get-Command cbc).Source)" -ForegroundColor Green } else { Write-Host ' · 未探测到 codebuddy(CodeBuddy Code)。官方安装:' -ForegroundColor Yellow Write-Host ' irm https://www.codebuddy.cn/cli/install.ps1 | iex' -ForegroundColor Cyan } # [v0.9.0] DeepSeek Harness(bin `dsh`)。本仓约定「官网有原生安装器时不附带 npm」,这家是 # 显式例外:官方当前只发 npm 包 @deepseek-ai/dsh(2026-08-13 核实 0.1.0-rc.6,无原生安装脚本、 # 无 brew/winget 入口),故这里只能给 npm 命令;官方一旦发原生安装器就换回原生入口。 if (Get-Command dsh -ErrorAction SilentlyContinue) { Write-Host " ✓ dsh 已安装:$((Get-Command dsh).Source)" -ForegroundColor Green } else { Write-Host ' · 未探测到 dsh(DeepSeek Harness)。官方安装(需先有 Node.js):' -ForegroundColor Yellow Write-Host ' npm i -g @deepseek-ai/dsh' -ForegroundColor Cyan } # ── 5. 注册登录自启任务(daemon service install → schtasks /SC ONLOGON,用户态、无 UAC)── # [plan 16 WS-5]:注入 KITE_LAUNCHER_PATH + KITE_DAEMON_PAYLOAD_DIR → 自启注册的是 **launcher** # (永不自更新、supervise + 回滚 daemon payload),launcher 与 install 用同一 payload 目录。 Info "注册并启动登录自启任务(幂等覆盖)" $env:KITE_DAEMON_CONF_FILE = $ConfFile $env:KITE_LAUNCHER_PATH = $LauncherPath $env:KITE_DAEMON_PAYLOAD_DIR = $PayloadDir & $DaemonBin service install if ($LASTEXITCODE -ne 0) { Die "任务注册失败——可手动跑:& `"$DaemonBin`" service install" } # ── 6. 设备授权:用户态登录会话能弹浏览器(首选);同时 daemon 把机器码写 $ConfDir\device-auth.txt # 作兜底(无默认浏览器 / 远程时),这里轮询读出来在本窗口显示,供用户授权 ── Info "等待 daemon 生成设备授权信息(最多 ~60s;有桌面时已自动弹浏览器)…" $shown = $false for ($i = 0; $i -lt 30; $i++) { if (Test-Path $AuthFile) { $authInfo = Get-Item $AuthFile -ErrorAction SilentlyContinue if ($null -eq $authInfo -or $authInfo.LastWriteTimeUtc -lt $EnrollmentStartedUtc) { Start-Sleep -Seconds 2 continue } Write-Host "" Write-Host " ┌─ 云鸢设备授权 ────────────────────────────────" -ForegroundColor Yellow # 文件是 UTF-8(daemon 写),PowerShell 5.1 的 Get-Content 默认按 ANSI/GBK 读会乱码 → 显式 utf8。 Get-Content $AuthFile -Encoding utf8 | ForEach-Object { Write-Host " │ $_" -ForegroundColor Yellow } Write-Host " └───────────────────────────────────────────────" -ForegroundColor Yellow $shown = $true break } Start-Sleep -Seconds 2 } if (-not $shown) { Write-Host "(暂未见本轮授权文件;若已弹浏览器请在浏览器完成授权,或稍后查 $AuthFile)" -ForegroundColor DarkYellow } Write-Host "" Write-Host "✓ 云鸢 daemon 已安装并启动(用户态登录自启)。" -ForegroundColor Green Write-Host "" Write-Host " · 配置:$ConfFile" Write-Host " · 自启:登录用户态计划任务(schtasks ONLOGON),继承你的 PATH / 凭据 / 桌面" Write-Host "" Write-Host "首次启动会要求授权这台机器接入你的灵澜账号:" Write-Host " · 自动弹浏览器登录授权(无默认浏览器时显示机器码 XXXX-XXXX + 网址,在任意设备授权)" Write-Host "" Write-Host "查看任务 / 卸载:schtasks /Query /TN KiteDaemon ;卸载 & `"$DaemonBin`" service uninstall"