diff --git a/docs-site/src/content/docs/getting-started/install/windows.mdx b/docs-site/src/content/docs/getting-started/install/windows.mdx index 35619a9d3..ac9647339 100644 --- a/docs-site/src/content/docs/getting-started/install/windows.mdx +++ b/docs-site/src/content/docs/getting-started/install/windows.mdx @@ -78,6 +78,37 @@ iwr -useb https://github.com/waired-ai/waired-agent/releases/latest/download/ins You can add it later from an administrator prompt with `waired runtimes install ollama`. +## If Windows refuses to run a program + +Waired's programs are not yet signed with a certificate Windows recognises, so +Smart App Control — or another application-control policy — can refuse to run +them. Both installers — the PowerShell line above and the downloadable setup +program (`WairedSetup--x64.exe` on the +[releases page](https://github.com/waired-ai/waired-agent/releases/latest)) — +therefore run the programs first, from a temporary folder, and stop if Windows +refuses one. That happens before anything is installed, stopped or replaced: + +``` +[waired] Checking the new programs run on this computer before replacing anything +[waired] Windows will not run the new waired-agent.exe on this computer: +[waired] An Application Control policy has blocked this file +``` + +The setup program says the same on its **Preparing to Install** page and in its +log, ending with `Nothing has been installed, removed or replaced.` That is the +whole outcome: nothing is half-installed, and Claude Code has not been pointed +at Waired — an install that stops here leaves the computer as it was. + +The refusal is per file and changes on its own — a file this computer refused +in the morning can run in the afternoon with nothing changed on the machine — +so trying again later is a real option. Only two programs stop the install: +`waired.exe` (the `waired` command) and `waired-agent.exe` (the background +service). If only the Waired app (`waired-tray.exe`) is refused, setup +continues with a warning; the service and the `waired` command are unaffected. + +[Updating](/getting-started/update/#if-an-update-does-not-finish) runs the +same check before it replaces the version you have. + ## What gets installed | | | diff --git a/docs-site/src/content/docs/ja/getting-started/install/windows.mdx b/docs-site/src/content/docs/ja/getting-started/install/windows.mdx index 08140a8f5..42414bdc3 100644 --- a/docs-site/src/content/docs/ja/getting-started/install/windows.mdx +++ b/docs-site/src/content/docs/ja/getting-started/install/windows.mdx @@ -5,7 +5,7 @@ meta: audience: Windows 11 の人 needs: 管理者ターミナル time: 5 分 -sourceHash: a6b381394ea8538f +sourceHash: f4643e3ef40d5879 --- import { Aside, Steps, CardGrid, LinkCard } from '@astrojs/starlight/components'; @@ -77,6 +77,37 @@ iwr -useb https://github.com/waired-ai/waired-agent/releases/latest/download/ins あとから管理者プロンプトで `waired runtimes install ollama` を実行すれば追加できます。 +## Windows がプログラムの実行を拒否したとき + +Waired のプログラムはまだ Windows が認識する証明書で署名されていないため、 +Smart App Control(または別のアプリケーション制御ポリシー)が実行を拒否することが +あります。そのため、上の PowerShell の 1 行も、ダウンロードして実行するセットアップ +プログラム([リリースページ](https://github.com/waired-ai/waired-agent/releases/latest)の +`WairedSetup--x64.exe`)も、まず一時フォルダからプログラムを実行してみて、 +Windows が拒否した場合はそこで止まります。止まるのは、何かをインストール・停止・ +置き換えする前です。 + +``` +[waired] Checking the new programs run on this computer before replacing anything +[waired] Windows will not run the new waired-agent.exe on this computer: +[waired] An Application Control policy has blocked this file +``` + +セットアッププログラムは同じ内容を **Preparing to Install** ページとログに出し、 +最後に `Nothing has been installed, removed or replaced.` と言います。結果はそれだけ +です。中途半端に入ったものはなく、Claude Code が Waired に向けられることもありません。 +ここで止まったインストールは、このパソコンを元のままにして終わります。 + +拒否はファイル単位で、しかも勝手に変わります。朝に拒否されたファイルが、 +このパソコン側は何も変わっていないのに午後には動くことがあります。あとで +もう一度試すのは現実的な選択肢です。インストールを止めるのは 2 つのプログラム +だけです: `waired.exe`(`waired` コマンド)と `waired-agent.exe`(常駐サービス)。 +拒否されたのが Waired アプリ(`waired-tray.exe`)だけの場合、セットアップは警告を +出して続行します。常駐サービスと `waired` コマンドには影響しません。 + +[アップデート](/ja/getting-started/update/#アップデートが最後まで進まなかったとき)も、 +いまのバージョンを置き換える前に同じチェックをします。 + ## インストールされるもの | | | diff --git a/docs/knowledges/20260904/0210-inno-can-only-decline-before-it-installs.md b/docs/knowledges/20260904/0210-inno-can-only-decline-before-it-installs.md new file mode 100644 index 000000000..3a5f00f10 --- /dev/null +++ b/docs/knowledges/20260904/0210-inno-can-only-decline-before-it-installs.md @@ -0,0 +1,120 @@ +# Inno Setup が「やめる」と言えるのは、置き始める前だけ (20260904 02:10) + +## Issue + +waired-agent#1181。GUI インストーラ(`packaging/windows/waired-setup.iss`)が、 +Smart App Control に拒否されたサービス登録を握りつぶして完走し、 +`waired claude enable` まで実施して成功を報告していた。 +「失敗したらインストールを失敗させる」を実装しようとして、Inno Setup 6 の +どのフックなら中断できるのかを、ソース(jrsoftware/issrc、タグ `is-6_7_3`)と +実機で確かめた。**答えは「1 か所だけ」**だった。 + +## Learnings + +### 中断できる場所は `PrepareToInstall` だけ + +| フック | インストールを失敗させられるか | 根拠 | +|---|---|---| +| `PrepareToInstall` が非空文字列を返す | **できる**。終了コード **7**、`[Files]` の前・サービス停止の前・レジストリの前 | `Setup.WizardForm.pas` `ClickThroughPages`(silent 時は `LoggedMsgBox(..., Suppressible=True)` → `SetupExitCode := ecPrepareToInstallFailed` → `Abort`) | +| `[Files]` の `AfterInstall` / `BeforeInstall` で例外 | **できない**。Inno が意図的に握りつぶす | `Setup.MainFunc.pas` `NotifyInstallEntry`: *"Don't allow exceptions raised by Before/AfterInstall functions to be propagated out"* → `Application.HandleException(nil)` | +| `[Run]` エントリの失敗 | **できない**。結果を読まない | `Setup.MainForm.pas` `ProcessRunEntries` | +| `CurStepChanged(ssPostInstall)` で例外 | **できない**。`SetStep(ssPostInstall, True)` が握りつぶす | `Setup.MainForm.pas` `SetStep` | +| `[Files]` の `Check` で例外 | 失敗はさせられるが**使えない**。Ready ページの `CalcFilesSize` と `CopyFiles` の **2 回**評価される | `Setup.Install.HelperFunc.pas` `CalcFilesSize` / `Setup.Install.pas` `CopyFiles` | + +`AfterInstall` は実機でも確かめた(Windows 11 / Inno Setup 6.7.3、SAC off、 +`/VERYSILENT /SUPPRESSMSGBOXES`): `RaiseException` を投げても +**終了コード 0・全ファイル設置済み**で完走した。設計を組み直すまで、 +これに気づかないまま「exit 4 + ロールバックになるはず」と読んでいた +(`Setup.Install.pas` の `except` は確かにそう書いてあるが、そこまで例外が届かない)。 + +実行順は `ssInstall` → `[Files]`/`[Registry]`/`[Icons]` → `[Run]`(非 postinstall) +→ `ssPostInstall` → 完了ページ → `[Run]`(postinstall) → `ssDone` +(`Setup.MainForm.pas:233-254`)。**`[Run]` は `ssPostInstall` より前**。 + +### だから、失敗しうる仕事は全部 `PrepareToInstall` に置く + +`.iss` はこう組み直した: + +- 3 本の exe を `Flags: dontcopy noencryption` で**1 回だけ**埋め込み、 + `ExtractTemporaryFile` で `{tmp}` に出し、`{app}\.waired-staging` に置いて + **実際に起動して**確かめる(`install.ps1` の `Get-StagedBinaryChecks` と同じ表)。 + 設置は `Source: "{tmp}\x.exe"; Flags: external` — セットアップ実行ファイルに + 2 つ目のコピーは入らない。 +- **`waired-agent.exe` だけは Inno に設置させない**。`PrepareToInstall` が自分で + 置き、`install` / `start` してサービスが Running になるまで確かめる。ここが + 「まだ断れる」最後の瞬間だから。`[UninstallDelete]` がその 1 本を消す。 +- `claude enable` は `ssPostInstall` で、サービスが Running のときだけ。 + +`ExtractTemporaryFile` は `DestName` のファイル名一致で探すが、 +**`LocationEntry <> -1`(= 埋め込み済み)しか見ない**ので、同名の `external` +エントリと共存しても取り違えない(`Setup.ExtractFileFunc.pas`)。 + +### Inno の例外ダイアログはサイレント実行を止めない + +`Application.HandleException` は Inno の `ShowExceptionMsgText` を経由し、 +`LoggedMsgBox(..., Suppressible=True)` を使う。`/SUPPRESSMSGBOXES` で答えられる +ので、**例外がサイレントインストールを固まらせることはない**(waired#760 で +踏んだ「抑制されない MsgBox」とは別物)。実機で 2.1 s で完走を確認。 + +### ロールバックは「元から在ったファイル」を消さない + +`Setup.UninstallLog.pas:894` — `CallFromUninstaller or (ExtraData and +utDeleteFile_ExistedBeforeInstall = 0)`。つまりインストール中の巻き戻しでは、 +インストール前から存在したファイルは削除されない。更新経路で自前に書き戻した +旧バイナリは、Inno のロールバックを生き延びる。 + +### 実機で確かめた 4 通り(Windows 11、SAC off、Inno Setup 6.7.3) + +| 状況 | 結果 | +|---|---| +| 新規 / `waired-agent.exe` が起動できない | exit **7**、`%ProgramFiles%\Waired` は**空**、サービス無し、レジストリ無し、managed-settings 無し | +| 新規 / 起動はするがサービスが上がらない | exit **7**、同上(置いた `waired-agent.exe` も消える) | +| 新規 / 正常 | exit 0、**インストーラが返った時点でサービスが Running**、managed-settings 書き込み済み | +| 更新 / 新しい agent のサービスが上がらない | exit **7**、サービスは Running のまま、`waired-agent.exe` は**バイト一致で元のまま**、managed-settings 無変更 | + +### おまけ: SAC は「その場でコンパイルした未署名 exe」を拒否する + +2026-09-03、Windows 11 Pro(SAC 有効)で、ビルドしたばかりの +`WairedSetup-*.exe` **そのもの**が拒否された(CodeIntegrity 3077 + 3033 + +3118 Smart App Control Block Details)。GUI インストーラが起動すらしないので、 +この機では機能検証が回せない。判定はファイル単位で時間とともに変わるので、 +**方針の拒否は実機で再現できない** — 壊したペイロード(起動できないファイル、 +`where.exe`)で同じ形を作るしかない。`install.ps1` の #1087 契約テストと同じ手口。 + +### 報告と状態は別物 — `sc.exe interrogate` で確かめる + +`waired-agent.exe start` は Running を待って非ゼロで返す実装なので、その終了コードは +本来「サービスが上がった」の答えになる。しかし**インストーラは自分が置いたものが本物か +知らない**。CI(GitHub hosted runner)で `where.exe` を `waired-agent.exe` の身代わりに +したとき、`where.exe start` が **exit 0** を返し(PATH に `start` に一致する何かがあった)、 +インストーラは成功と報告した。同じ身代わりが手元の Windows 機では exit 1 だった +— **`where.exe` の終了コードは PATH の中身で変わる**。 + +そこで、報告に加えて SCM に状態を聞く: + +``` +sc.exe interrogate waired-agent -> 0 = 応答した(= 動いている) + 1062 = 登録済みだが停止中 + 1060 = 未登録 +``` + +**終了コードだけで判定でき、`sc query` の出力(ローカライズされる)を読まない。** +実測(常に exit 0 を返すだけの Go スタブを `waired-agent.exe` に置いた場合): +`install` も `start` も 0 を返すが interrogate が 1062 を返し、インストーラは +`the service is registered but is not running` で exit 7、旧バイナリを戻して復帰した。 + +### `.iss` を編集するときの小さな罠 2 つ + +- **Pascal の `{ }` コメントの中に `{app}` や `{tmp}` を書けない。** 最初の `}` で + コメントが閉じ、残りがコードとして読まれる。Inno 定数に触れるコメントは `//` にする。 + 既存コードが `{ }` を使っているのは、たまたま中に定数が無いから。 +- **セクション名でファイルを切るスクリプトは、その名前に触れたコメントを食う。** + `s.index('[Code]')` で分割したら、`[Run]` の説明文にあった `[Code]` に当たって + `[Run]`/`[UninstallRun]`/`[UninstallDelete]` ごと消えた。行頭アンカー + (`^\[Code\]$`)で切ること。 + +## Refs +- https://github.com/waired-ai/waired-agent/issues/1181 +- docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md +- docs/knowledges/20260829/1740-sac-verdict-is-per-file-and-moves.md +- https://github.com/jrsoftware/issrc (tag `is-6_7_3`) diff --git a/packaging/windows/README.md b/packaging/windows/README.md index 919b215e5..20d0119fc 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -102,7 +102,10 @@ Foundation application). ## Verification `iscc` validates the `.iss` at compile time and surfaces syntax / -referenced-file errors loudly. End-to-end: +referenced-file errors loudly. `go test ./scripts/install/` reads the script +itself and holds its pre-flight table against install.ps1's. The install test's +`-ExeVariant` leg compiles and runs the installer on a Windows runner, including +two deliberately broken payloads that prove it declines. End-to-end, by hand: ```powershell # Build the artifacts @@ -122,3 +125,16 @@ icacls.exe "$env:ProgramData\waired\secrets" # confirm restricted DACL Uninstall via Settings -> Apps -> Waired -> Uninstall. Confirm service is gone, `%ProgramFiles%\Waired\` is empty, and `%ProgramData%\waired\` is preserved unless you chose to drop it. + +To see it decline (waired-agent#1181), replace one program with something +Windows will not start and rebuild: + +```powershell +Set-Content dist\windows-amd64\waired-agent.exe -Value 'not a program' -NoNewline +iscc /DAppVersion=1.2.3 /DNoCompression packaging\windows\waired-setup.iss +.\dist\WairedSetup-1.2.3-x64.exe /VERYSILENT /SUPPRESSMSGBOXES /LOG=$env:TEMP\s.log +$LASTEXITCODE # 7 -- "Preparing to Install" declined; nothing was installed +``` + +`/DNoCompression` is a build-time switch for throwaway payloads like this one; +it skips the minute of lzma2/ultra. Shipping builds never pass it. diff --git a/packaging/windows/waired-setup.iss b/packaging/windows/waired-setup.iss index f6f5aba54..406ca06bf 100644 --- a/packaging/windows/waired-setup.iss +++ b/packaging/windows/waired-setup.iss @@ -2,14 +2,47 @@ ; ; Builds a single self-extracting WairedSetup--x64.exe that: ; - elevates to Administrator -; - extracts waired.exe / waired-agent.exe / waired-tray.exe to -; %ProgramFiles%\Waired\ -; - runs `waired-agent.exe install` so the Go side handles SCM +; - runs waired.exe / waired-agent.exe / waired-tray.exe from a staging +; directory FIRST, and stops if Windows will not run one of them +; (docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md) +; - extracts those same three programs to %ProgramFiles%\Waired\ +; - runs `waired-agent.exe install` + `start` so the Go side handles SCM ; registration, Event Log source, and the restrictive DACL on -; %ProgramData%\waired\secrets (no duplicated logic here) +; %ProgramData%\waired\secrets (no duplicated logic here), and fails the +; installation if the service does not end up running +; - only then points Claude Code at Waired ; - drops a Start Menu entry for "Waired" (the tray) ; - on uninstall, runs `waired-agent.exe uninstall` ; +; PrepareToInstall is the ONLY place this script can decline. Measured against +; the Inno Setup 6 sources and recorded in +; docs/knowledges/20260904/0210-inno-can-only-decline-before-it-installs.md: +; +; PrepareToInstall returning a message stops Setup with exit code 7 +; before anything is created, stopped or replaced. +; Under /VERYSILENT the message goes to the log and a +; suppressible box, so an unattended run ends. +; [Files] AfterInstall CANNOT fail the installation. Inno catches the +; exception on purpose -- "Don't allow exceptions +; raised by Before/AfterInstall functions to be +; propagated out" (Setup.MainFunc.pas +; NotifyInstallEntry). +; [Run] CANNOT fail it either: Inno discards the result. +; ssPostInstall CANNOT fail it either: SetStep(ssPostInstall, True) +; handles the exception and carries on. +; [Files] Check evaluated twice (CalcFilesSize on the Ready page and +; again in CopyFiles), so it cannot carry work. +; +; So everything that may fail happens in PrepareToInstall, including placing +; waired-agent.exe and bringing its service up. That is also why this script +; installs waired-agent.exe itself rather than through [Files]: the service has +; to be running before Setup has committed to anything. +; +; waired-agent#1181 is what this is about: a service registration blocked by +; Smart App Control was logged as `CreateProcess failed; code 4551.`, Setup +; carried on, enabled the Claude Code integration and reported success -- +; leaving Claude Code pointed at a gateway that would never listen. +; ; AppId is the immutable identity Inno Setup uses to detect prior ; versions for upgrades. NEVER change it -- if it changes between ; releases, Inno treats the old install as a separate app and leaves @@ -44,8 +77,27 @@ ArchitecturesInstallIn64BitMode=x64compatible WizardStyle=modern OutputDir=dist OutputBaseFilename=WairedSetup-{#AppVersion}-x64 +; Always write a log. An install that stops because Windows refused a program +; -- or one that succeeded and left something odd -- is diagnosed from this +; file, and waired-agent#1181 was only readable because the run happened to +; have one. Inno puts it in %TEMP%\Setup Log*.txt unless /LOG= names one. +SetupLogging=yes +; The install stage is a few seconds of copying: everything that can fail has +; already happened in PrepareToInstall, and by then the service is registered +; and running. A cancel in that window would roll back files Inno owns and +; leave the service it does not know about, so there is nothing to gain by +; offering it. +AllowCancelDuringInstall=no +; NoCompression is for throwaway builds only: the install test compiles +; deliberately broken payloads to prove Setup stops on them, and pays a minute +; of lzma2/ultra per build otherwise. Shipping builds never define it. +#ifdef NoCompression +Compression=none +SolidCompression=no +#else Compression=lzma2/ultra SolidCompression=yes +#endif ; Use the existing tray "connected" icon for both the installer's own ; icon and the Add/Remove Programs entry. A larger / hi-res icon can ; replace this later without touching the rest of the install flow. @@ -62,10 +114,24 @@ SourceDir=..\.. Name: "english"; MessagesFile: "compiler:Default.isl" [Files] -Source: "dist\windows-amd64\waired.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "dist\windows-amd64\waired-agent.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "dist\windows-amd64\waired-tray.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "dist\windows-amd64\VERSION"; DestDir: "{app}"; Flags: ignoreversion +; The three programs are embedded ONCE, with dontcopy, so PrepareToInstall can +; extract and actually run them before anything on this computer is created, +; stopped or replaced. They are then installed from those extracted copies with +; `external`, which is why there is no second copy inside the setup executable. +; With solid compression the extraction reads the stream in order, so these +; come first. ExternalSize is deliberately not set: the destination page is +; disabled, so the figure is never shown, and the alternative is a compile-time +; path expression that is easy to get silently wrong. +Source: "dist\windows-amd64\waired.exe"; Flags: dontcopy noencryption +Source: "dist\windows-amd64\waired-agent.exe"; Flags: dontcopy noencryption +Source: "dist\windows-amd64\waired-tray.exe"; Flags: dontcopy noencryption + +; waired-agent.exe is deliberately NOT here: PrepareToInstall places it and +; brings its service up, because that is the last moment Setup can still +; decline (see the header). [UninstallDelete] removes it. +Source: "{tmp}\waired.exe"; DestDir: "{app}"; Flags: external ignoreversion +Source: "{tmp}\waired-tray.exe"; DestDir: "{app}"; Flags: external ignoreversion +Source: "dist\windows-amd64\VERSION"; DestDir: "{app}"; Flags: ignoreversion Source: "dist\windows-amd64\LICENSE"; DestDir: "{app}"; Flags: ignoreversion Source: "dist\windows-amd64\THIRD_PARTY_LICENSES"; DestDir: "{app}"; Flags: ignoreversion @@ -93,56 +159,21 @@ Root: HKLM; Subkey: "SOFTWARE\Waired"; ValueType: string; ValueName: "InstallDir ; init's "Route now?" prompt that the CLI installer (install.ps1) dropped: ; there the post-init `waired claude enable` step overrode an interactive ; "no" and was removed, with the choice forwarded into init via -; --skip-claude-route. Do not remove the [Run] claude-enable step below — -; that would leave GUI installs unrouted. +; --skip-claude-route. Do not remove the claude-enable step in +; CurStepChanged(ssPostInstall) — that would leave GUI installs unrouted. Name: "claudeproxy"; \ Description: "Route Claude Code through Waired via Claude Code managed settings (points ANTHROPIC_BASE_URL at local inference, no credential; transparently falls back to the real Anthropic API). No CA certificate or hosts-file change."; \ GroupDescription: "Claude Code integration:" [Run] -; Register the Windows Service. The Go-side install handler picks up -; its own exe path via os.Executable(), so the SCM ImagePath ends up -; pointing at {app}\waired-agent.exe (not the staging path Inno -; extracted from). -; -; Check: ShouldRegisterAgent — register ONLY on a fresh install. On an -; upgrade-in-place the service is already registered (and `install` would -; error out with "already installed"); its ImagePath already points at -; {app}\waired-agent.exe, so the just-copied binary is picked up by the -; stop/start in CurStepChanged (see [Code]) with no re-registration. -Filename: "{app}\waired-agent.exe"; Parameters: "install"; \ - Flags: runhidden waituntilterminated; \ - Check: ShouldRegisterAgent; \ - StatusMsg: "Registering waired-agent Windows Service..." - -; On a fresh install, START the service right after registering it, so the -; daemon is already running when the operator later signs in with `waired -; init` (this GUI installer does not run init itself). init then attaches to -; the running agent and takes the daemon-driven onboarding path (waired#835 -; §11.2) rather than the legacy standalone enroll. Fresh-only -; (ShouldRegisterAgent); on an upgrade CurStepChanged(ssPostInstall) owns the -; restart. Safe before sign-in: the daemon idles until enrolment (#177); -; parity with the .deb postinst start-on-fresh-install and install.ps1's -; Ensure-AgentRunning. -Filename: "{app}\waired-agent.exe"; Parameters: "start"; \ - Flags: runhidden waituntilterminated; \ - Check: ShouldRegisterAgent; \ - StatusMsg: "Starting waired-agent Windows Service..." - -; Enable Claude Code routing via managed settings (only when the task is -; checked): writes %ProgramFiles%\ClaudeCode\managed-settings.json pointing -; ANTHROPIC_BASE_URL at waired's local gateway (no credential, no CA, no -; hosts edit) and sweeps any residual retired-MITM artifacts. Runs AFTER the -; service-register entry above; elevated, as the managed-settings write needs -; admin. Replaces the removed `waired proxy install` (waired#750). -Filename: "{app}\waired.exe"; Parameters: "claude enable"; \ - Tasks: claudeproxy; \ - Flags: runhidden waituntilterminated; \ - StatusMsg: "Enabling Claude Code routing (managed settings)..." - ; Optional: launch the tray immediately after install so its first ; run can write its HKCU\...\Run autostart entry via ; internal/platform/autostart/autostart_windows.go. +; +; Nothing else lives here. [Run] entries are executed after the install stage +; has been committed, and Inno discards their result -- a failed one leaves +; Setup reporting success. The service registration and the Claude Code +; integration live in the script section below, for that reason. Filename: "{app}\waired-tray.exe"; \ Description: "Launch Waired now (recommended -- registers per-user autostart)"; \ Flags: nowait postinstall skipifsilent runasoriginaluser @@ -167,15 +198,56 @@ Filename: "{app}\waired-agent.exe"; Parameters: "uninstall"; \ ; preserves identity / keys. Users who want a clean slate can use the ; checkbox below to wipe state too. Type: files; Name: "{app}\VERSION" +; PrepareToInstall placed this one, so Inno's uninstall log does not know it. +; The .displaced-* pattern catches an image Windows would not let Setup +; overwrite and which was renamed aside instead. +Type: files; Name: "{app}\waired-agent.exe" +Type: files; Name: "{app}\waired-agent.exe.displaced-*" +; Setup's own working directories. They are removed as soon as they have served +; their purpose, so these entries only catch a run that was killed mid-way. +Type: filesandordirs; Name: "{app}\.waired-staging" +Type: filesandordirs; Name: "{app}\.waired-rollback" [Code] +const + // The program that becomes the Windows Service. Setup places this one itself + // (see the header) and everything else goes through [Files]. + AgentProgram = 'waired-agent.exe'; + // Where the programs are tried before they are installed. Directly under the + // install directory, matching install.ps1's staging directory: they are + // started from the path prefix they will run from, and a computer that + // refuses to execute anything out of %TEMP% does not turn into a false + // refusal. + StagingDirName = '.waired-staging'; + // Where an upgrade's previous waired-agent.exe is kept while the new one goes + // in, so a service that will not start can be put back. + RollbackDirName = '.waired-rollback'; + var - WipeStatePage: TInputOptionWizardPage; - // True when a waired-agent Windows Service is already registered at the - // start of setup (i.e. this run is an upgrade-in-place over a prior - // install). Set in CurStepChanged(ssInstall); read by ShouldRegisterAgent - // and the ssPostInstall restart. + // True when a waired-agent Windows Service was already registered when Setup + // started, i.e. this run is an upgrade-in-place over a prior install. gAgentServiceExisted: Boolean; + // True when Setup had to create the install directory just to stage into, so + // a refusal can leave the computer exactly as it found it. + gAppDirCreatedByPreflight: Boolean; + // True once the service is registered AND running. Read in ssPostInstall + // before anything is done to Claude Code. + gAgentRunning: Boolean; + +function AppDir(): String; +begin + Result := AddBackslash(ExpandConstant('{app}')); +end; + +function StagingDir(): String; +begin + Result := AppDir() + StagingDirName; +end; + +function RollbackDir(): String; +begin + Result := AppDir() + RollbackDirName; +end; // AgentServiceExists reports whether the waired-agent service is registered // with the SCM, via a read-only `sc.exe query`. Exit code 0 => registered @@ -190,49 +262,355 @@ begin Result := (ResultCode = 0); end; -// ShouldRegisterAgent gates the `[Run] waired-agent install` step to fresh -// installs only. On an upgrade the service is already registered (and -// `install` would error), so we keep the existing registration and just -// restart onto the new binary in CurStepChanged(ssPostInstall). -function ShouldRegisterAgent(): Boolean; +// AgentServiceIsRunning asks the SCM to interrogate the service. Exit codes +// only, so nothing parses localised `sc query` output: 0 means the service +// answered, 1062 (ERROR_SERVICE_NOT_ACTIVE) that it is registered but not +// running, 1060 that it is not registered at all. +// +// It is here because `waired-agent.exe start` REPORTS whether the service came +// up, and a report is not a state -- the distinction waired-agent#1087 and +// #1181 are both about. Measured: a stand-in binary that exits 0 without ever +// becoming a service passes the report and fails this. +// +// Three attempts: `start` has already waited for Running, so one answer is +// normally enough, but the SCM can still be settling and a single transient +// no is not worth failing an install over. +function AgentServiceIsRunning(): Boolean; +var + ResultCode, Attempt: Integer; +begin + Result := False; + for Attempt := 1 to 3 do begin + if Exec(ExpandConstant('{sys}\sc.exe'), 'interrogate waired-agent', '', + SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then begin + Result := True; + Exit; + end; + Sleep(1000); + end; +end; + +// WhyItWillNotRun starts Path with Params and returns '' when Windows ran it, +// or the reason it did not. +// +// Exec returns False when CreateProcess itself failed and puts GetLastError in +// ResultCode (Setup.InstFunc.pas) -- 4551 is what an application-control +// refusal looks like there. SysErrorMessage turns that into the OS's own words, +// which is the text install.ps1 surfaces through Win32Exception, so both +// installers say the same thing about the same computer. +function WhyItWillNotRun(const Path, Params: String; const RequireZeroExit: Boolean): String; +var + ResultCode: Integer; begin - Result := not gAgentServiceExisted; + Result := ''; + if not FileExists(Path) then begin + Result := 'it is not in this installer'; + Exit; + end; + if not Exec(Path, Params, ExtractFileDir(Path), SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin + Result := Trim(SysErrorMessage(ResultCode)); + if Result = '' then + Result := Format('Windows would not start it (error %d)', [ResultCode]); + Exit; + end; + if RequireZeroExit and (ResultCode <> 0) then + Result := Format('it ran but exited with code %d', [ResultCode]); end; -procedure CurStepChanged(CurStep: TSetupStep); +// RunInstalledProgram runs one of the installed programs and returns '' when it +// succeeded, or the reason it did not -- Windows refusing to start it and the +// program itself failing are both reported, because ignoring either is what +// waired-agent#1181 was. +function RunInstalledProgram(const Name, Params: String): String; var + Path: String; ResultCode: Integer; begin - if CurStep = ssInstall then begin - // Before [Files] copies: on an upgrade, stop the running agent so its - // locked waired-agent.exe can be overwritten (Windows locks a running - // binary, unlike the Unix in-place swap the .deb / macOS paths use). - // Delegate to the Go SCM logic, matching the install/uninstall steps - // (no duplicated service logic here). On a fresh install the service is - // absent (and {app}\waired-agent.exe does not yet exist), so we skip. - gAgentServiceExisted := AgentServiceExists(); - if gAgentServiceExisted then - Exec(ExpandConstant('{app}\waired-agent.exe'), 'stop', '', - SW_HIDE, ewWaitUntilTerminated, ResultCode); - end else if CurStep = ssPostInstall then begin - // After the new binaries are in place: restart the agent onto the new - // exe so an upgrade never leaves the service stopped or on the old - // binary. Fresh installs are registered AND started by the [Run] steps - // above (so a later `waired init` attaches to the running daemon and - // takes the daemon-driven onboarding path -- waired#835 §11.2); here we - // cover only the upgrade path (parity with the .deb postinst - // restart-on-upgrade and install.ps1 -Update). A no-op if the - // proxy-install [Run] step already brought it up. - if gAgentServiceExisted then - Exec(ExpandConstant('{app}\waired-agent.exe'), 'start', '', - SW_HIDE, ewWaitUntilTerminated, ResultCode); + Result := ''; + Path := AppDir() + Name; + if not Exec(Path, Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin + Result := Trim(SysErrorMessage(ResultCode)); + if Result = '' then + Result := Format('Windows would not start %s (error %d)', [Name, ResultCode]); + Exit; end; + if ResultCode <> 0 then + Result := Format('`%s %s` exited with code %d', [Name, Params, ResultCode]); end; -procedure InitializeWizard(); +// ProgramName returns the name of shipped program I (0..2). +function ProgramName(const I: Integer): String; begin - // Uninstall-time "drop config" toggle is handled in - // CurUninstallStepChanged below. Nothing to do here at install time. + case I of + 0: Result := 'waired.exe'; + 1: Result := AgentProgram; + else + Result := 'waired-tray.exe'; + end; +end; + +// CleanUpWorkDirs clears the directories Setup works in. AlsoRemoveAppDir puts +// back the one other thing they can leave behind: an install directory this run +// created only so it had somewhere to stage into. RemoveDir only succeeds while +// the directory is empty, which is exactly when removing it is right. +procedure CleanUpWorkDirs(const AlsoRemoveAppDir: Boolean); +begin + DelTree(StagingDir(), True, True, True); + DelTree(RollbackDir(), True, True, True); + if AlsoRemoveAppDir and gAppDirCreatedByPreflight then + RemoveDir(ExpandConstant('{app}')); +end; + +// StagedCheck says what to ask one program before it is installed, and whether +// a refusal stops the install. One line per program, and +// scripts/install/waired_setup_iss_test.go holds those lines against +// packaging/install/install.ps1's Get-StagedBinaryChecks -- two copies of one +// table forget different things. +// +// The ruling behind it is +// docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md: +// without the daemon there is no product, and without the CLI there is no +// `waired init`, `waired doctor` or `waired update`, so nobody could finish or +// diagnose the install. A refused Waired app costs the app, not the computer, +// so that one only warns. +// +// Never a bare word as an argument to waired-agent: its flag parsing stops at +// the first non-flag token, so `waired-agent.exe version` would start the +// daemon in the foreground and sit there. `-h` exits 1 by design +// (flag.ContinueOnError), which is why its exit code is not read. waired.exe is +// asked for `version --json` and its exit code IS read, because a program that +// starts and then cannot report its own version is not one to install either. +procedure StagedCheck(const Name: String; var Params: String; var RequireZeroExit, Fatal: Boolean); +begin + // A program this table does not know: ask the least of it, and let a refusal + // stop the install rather than pass unnoticed. + Params := '-h'; RequireZeroExit := False; Fatal := True; + if Name = 'waired.exe' then begin Params := 'version --json'; RequireZeroExit := True; Fatal := True; end; + if Name = 'waired-agent.exe' then begin Params := '-h'; RequireZeroExit := False; Fatal := True; end; + if Name = 'waired-tray.exe' then begin Params := '-h'; RequireZeroExit := False; Fatal := False; end; +end; + +// CheckProgramsRunHere puts the three programs where they will run from and +// starts each one. It returns '' when the install may go ahead, or the message +// the user should see instead. +function CheckProgramsRunHere(): String; +var + I: Integer; + Name, Params, Staged, Why: String; + RequireZeroExit, Fatal: Boolean; +begin + Result := ''; + gAppDirCreatedByPreflight := not DirExists(ExpandConstant('{app}')); + if not ForceDirectories(StagingDir()) then begin + Result := 'Setup could not create ' + StagingDir() + '.'; + Exit; + end; + + // Same sentence install.ps1 prints, so both installers say the same thing + // and docs-site can quote one line for both. + Log('Checking the new programs run on this computer before replacing anything'); + for I := 0 to 2 do begin + Name := ProgramName(I); + StagedCheck(Name, Params, RequireZeroExit, Fatal); + + ExtractTemporaryFile(Name); + Staged := AddBackslash(StagingDir()) + Name; + if not FileCopy(ExpandConstant('{tmp}\') + Name, Staged, False) then begin + Result := Format('Setup could not put %s where it can be tried.', [Name]); + Exit; + end; + + Why := WhyItWillNotRun(Staged, Params, RequireZeroExit); + if Why = '' then + Continue; + + if not Fatal then begin + LogFmt('The Waired app (%s) will not run on this computer: %s', [Name, Why]); + SuppressibleMsgBox( + Format('The Waired app (%s) will not run on this computer:', [Name]) + #13#10 + + ' ' + Why + #13#10#13#10 + + 'Setup continues; the background service and the waired command are not' + #13#10 + + 'affected, but the app will not open until Windows accepts that file.', + mbInformation, MB_OK, IDOK); + Continue; + end; + + Result := + Format('Windows will not run the new %s on this computer:', [Name]) + #13#10#13#10 + + ' ' + Why + #13#10#13#10 + + 'Waired''s programs are not signed with a certificate Windows recognises, so' + #13#10 + + 'Smart App Control (or another application-control policy) can refuse to run' + #13#10 + + 'them. The refusal is per file and can change on its own, so a later build --' + #13#10 + + 'or the same one, later -- may be accepted.' + #13#10#13#10 + + 'Nothing has been installed, removed or replaced.'; + Log(Result); + Exit; + end; +end; + +// SavePreviousAgent keeps a copy of the waired-agent.exe an upgrade is about to +// replace. CheckProgramsRunHere answers "will these programs run here"; it +// cannot answer "will they still be allowed to run in thirty seconds", and the +// verdict does move on its own. So the upgrade path carries its own way back +// (docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md). +procedure SavePreviousAgent(); +var + Source: String; +begin + DelTree(RollbackDir(), True, True, True); + if not ForceDirectories(RollbackDir()) then begin + Log('Could not create ' + RollbackDir() + ': an upgrade that fails will not be able to put the previous version back.'); + Exit; + end; + Source := AppDir() + AgentProgram; + if FileExists(Source) and not FileCopy(Source, AddBackslash(RollbackDir()) + AgentProgram, False) then + Log('Could not copy ' + Source + ' aside.'); +end; + +function RestorePreviousAgent(): Boolean; +var + Saved: String; +begin + Saved := AddBackslash(RollbackDir()) + AgentProgram; + Result := FileExists(Saved) and FileCopy(Saved, AppDir() + AgentProgram, False); +end; + +// PlaceAgentProgram puts the checked waired-agent.exe at its final path and +// returns '' or the reason it could not. +// +// Windows will not overwrite a mapped image, so a copy that fails is retried +// after renaming the old file aside -- the same move install.ps1 makes +// (Set-InstallDirFile), and the reason [UninstallDelete] sweeps +// waired-agent.exe.displaced-*. +function PlaceAgentProgram(): String; +var + Source, Dest, Aside: String; +begin + Result := ''; + Source := AddBackslash(StagingDir()) + AgentProgram; + Dest := AppDir() + AgentProgram; + if FileCopy(Source, Dest, False) then + Exit; + Aside := Dest + '.displaced-' + GetDateTimeString('yyyymmddhhnnss', #0, #0); + if not RenameFile(Dest, Aside) then begin + Result := Format('%s is in use and could not be replaced', [AgentProgram]); + Exit; + end; + LogFmt('%s was in use; the old copy is now %s', [AgentProgram, Aside]); + if not FileCopy(Source, Dest, False) then + Result := Format('%s could not be placed in %s', [AgentProgram, ExpandConstant('{app}')]); +end; + +// SetUpTheService places waired-agent.exe and brings its service up, and +// returns '' or the message the user should see instead of an install. +// +// `waired-agent.exe start` waits for the service to reach Running and exits +// non-zero if it does not (internal/platform/service/service_windows.go), so +// its exit code is the answer -- no parsing of localised `sc query` output. +function SetUpTheService(): String; +var + Why, Recovery: String; +begin + Result := ''; + gAgentServiceExisted := AgentServiceExists(); + if gAgentServiceExisted then begin + SavePreviousAgent(); + // Windows locks a running binary, unlike the Unix in-place swap the .deb / + // macOS paths use. Delegate to the Go SCM logic, matching the install and + // uninstall steps -- no duplicated service logic here. + Why := RunInstalledProgram(AgentProgram, 'stop'); + if Why <> '' then + Log('Could not stop the running waired-agent: ' + Why); + Why := ''; + end; + + WizardForm.StatusLabel.Caption := 'Setting up the waired-agent Windows Service...'; + Why := PlaceAgentProgram(); + if Why = '' then begin + // On an upgrade the service is already registered and `install` would error + // out with "already installed"; its ImagePath already points here, so the + // just-placed binary is what the start below brings up. + if not gAgentServiceExisted then + Why := RunInstalledProgram(AgentProgram, 'install'); + if Why = '' then + Why := RunInstalledProgram(AgentProgram, 'start'); + if (Why = '') and not AgentServiceExists() then + Why := 'the service is not registered with Windows afterwards'; + if (Why = '') and not AgentServiceIsRunning() then + Why := 'the service is registered but is not running'; + end; + + if Why = '' then begin + gAgentRunning := True; + Exit; + end; + + Log('waired-agent service setup failed: ' + Why); + if gAgentServiceExisted then begin + if RestorePreviousAgent() and (RunInstalledProgram(AgentProgram, 'start') = '') then + Recovery := 'The version you had is back and its background service is running again.' + else + Recovery := 'Setup could not put the previous version back. Install Waired again to repair it.'; + end else begin + // Leave no half-registered service, and no program, behind. + RunInstalledProgram(AgentProgram, 'uninstall'); + DeleteFile(AppDir() + AgentProgram); + Recovery := 'Nothing has been installed, and Claude Code was not changed.'; + end; + + Result := 'Waired''s background service did not start on this computer:' + #13#10#13#10 + + ' ' + Why + #13#10#13#10 + Recovery; +end; + +// PrepareToInstall is the only place this script can decline (see the header): +// returning a message stops Setup with exit code 7, before [Files], before the +// registry and Start Menu entries, and before Claude Code is looked at. +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := CheckProgramsRunHere(); + if Result = '' then + Result := SetUpTheService(); + CleanUpWorkDirs(Result <> ''); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Why: String; +begin + if CurStep <> ssPostInstall then + Exit; + CleanUpWorkDirs(False); + + // Claude Code routing, last, and only once the daemon is actually there. + // Writes %ProgramFiles%\ClaudeCode\managed-settings.json pointing + // ANTHROPIC_BASE_URL at waired's local gateway (no credential, no CA, no + // hosts edit) and sweeps any residual retired-MITM artifacts; elevated, as + // the managed-settings write needs admin. Replaces the removed + // `waired proxy install` (waired#750). + // + // waired-agent#1181: this used to be a [Run] entry that ran whatever had + // happened to the service, so a computer whose service never registered had + // its Claude Code pointed at a gateway that would never listen. It cannot be + // reached now without a running service -- and when it fails on its own, + // Claude Code simply keeps talking to api.anthropic.com, which is where it + // was, so it says so rather than failing the install. + if not WizardIsTaskSelected('claudeproxy') then + Log('Claude Code integration not selected; leaving Claude Code alone.') + else if not gAgentRunning then + Log('Claude Code integration skipped: the waired-agent service is not running.') + else begin + WizardForm.StatusLabel.Caption := 'Enabling Claude Code routing (managed settings)...'; + Why := RunInstalledProgram('waired.exe', 'claude enable'); + if Why <> '' then begin + Log('Claude Code routing was not enabled: ' + Why); + SuppressibleMsgBox( + 'Waired is installed, but Claude Code was not pointed at it:' + #13#10#13#10 + + ' ' + Why + #13#10#13#10 + + 'Claude Code keeps talking to api.anthropic.com. Run' + #13#10 + + '`waired claude enable` from an Administrator terminal to try again.', + mbError, MB_OK, IDOK); + end; + end; end; function InitializeUninstall(): Boolean; diff --git a/scripts/ci/docs-surface-guard.sh b/scripts/ci/docs-surface-guard.sh index 57f12e99a..a52565414 100755 --- a/scripts/ci/docs-surface-guard.sh +++ b/scripts/ci/docs-surface-guard.sh @@ -54,6 +54,9 @@ base=$(git merge-base FETCH_HEAD "${head_sha}") # printed wording, `waired doctor` # packaging/install/ the install / uninstall scripts # users run from the README +# packaging/windows/ the Windows GUI installer — its +# wizard pages, its checkbox, and what +# it says when it stops # internal/router/ which model the Auto-Selector picks # proto/hostfit/ the rule deciding whether a model # suits this machine at all @@ -80,7 +83,15 @@ base=$(git merge-base FETCH_HEAD "${head_sha}") # did — adding, retiring or withholding a manifest changes which model a # user is handed, which is the same thing proto/hostfit/ is on this list # for. -SURFACES='^(internal/gui/|cmd/waired-tray/|cmd/waired/|packaging/install/|internal/router/|proto/hostfit/|proto/catalog/|internal/catalog/|internal/agentgrade/)' +# packaging/windows/ was added by waired-ai/waired-agent#1181. The GUI +# installer is the only Windows install path a person can double-click, and +# it was the one install surface this list did not cover: its sibling +# packaging/install/ has been here from the start. That gap showed — +# getting-started/update.mdx already promised "A fresh install behaves the +# same way: it stops rather than leaving a computer with programs that +# cannot run", which was true of install.ps1 and not of the GUI installer, +# and nothing failed. +SURFACES='^(internal/gui/|cmd/waired-tray/|cmd/waired/|packaging/install/|packaging/windows/|internal/router/|proto/hostfit/|proto/catalog/|internal/catalog/|internal/agentgrade/)' # GENERATED is subtracted from what counts as a docs change, never from # what counts as a surface. diff --git a/scripts/dev/installtest-windows.ps1 b/scripts/dev/installtest-windows.ps1 index 56e84171f..01c70c34a 100644 --- a/scripts/dev/installtest-windows.ps1 +++ b/scripts/dev/installtest-windows.ps1 @@ -4043,23 +4043,25 @@ if ($ExeVariant) { ItStep "ExeVariant: silent install (/VERYSILENT)" # /MERGETASKS=!claudeproxy: uncheck the default-on claudeproxy task so - # the [Run] `waired claude enable` step does not write machine-wide - # managed-settings during this test install (the GUI installer is the - # sole decider of routing in its own flow — there is no `waired init` - # here). skipifsilent already suppresses the tray launch. + # the `waired claude enable` step (ssPostInstall since waired-agent#1181) + # does not write machine-wide managed-settings during this test install + # (the GUI installer is the sole decider of routing in its own flow — + # there is no `waired init` here). skipifsilent already suppresses the + # tray launch. The refusal cases below deliberately leave the task ON, + # so "no managed-settings.json afterwards" means something there. $p = Start-Process -FilePath $setup -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/MERGETASKS=!claudeproxy', "/LOG=$Work\innosetup.log" -Wait -PassThru if ($p.ExitCode -ne 0) { ItDie "WairedSetup exited $($p.ExitCode) (see $Work\innosetup.log)" } - # A fresh Inno install registers the service but does NOT start it (a - # real user gets it via `waired init` or the delayed-auto start after - # reboot) — start it explicitly, then assert like Tier 1. - Start-Service -Name $ServiceName -ErrorAction SilentlyContinue - ItStep "ExeVariant: Tier-1-level asserts" + # Nothing is started here on purpose. The installer registers AND starts + # the service itself, and waits for it to reach Running before it + # reports success (waired-setup.iss ProvisionAgentService) — so a + # Start-Service in the harness would hide the very thing + # waired-agent#1181 was about. Read the state the installer left. $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue if ($svc) { ItOk "service registered by the .exe installer" } else { ItBad "service not registered by the .exe installer" } - for ($i = 0; $i -lt 15 -and $svc -and $svc.Status -ne 'Running'; $i++) { Start-Sleep 1; $svc.Refresh() } - if ($svc -and $svc.Status -eq 'Running') { ItOk "service Running" } else { ItBad "service not Running (status=$($svc.Status))" } + if ($svc -and $svc.Status -eq 'Running') { ItOk "service already Running when the installer returned (#1181)" } + else { ItBad "service not Running when the installer returned (status=$($svc.Status)) — #1181 regression" } $startType = (Get-CimInstance Win32_Service -Filter "Name='$ServiceName'" -ErrorAction SilentlyContinue).StartMode if ($startType -match 'Auto') { ItOk "service start mode = $startType" } else { ItBad "service start mode = $startType (want Auto)" } foreach ($exe in 'waired.exe', 'waired-agent.exe', 'waired-tray.exe') { @@ -4071,6 +4073,84 @@ if ($ExeVariant) { $smGroup = Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\Waired' if (Test-Path -LiteralPath $smGroup) { ItOk "Start Menu group created by the .exe installer" } else { ItBad "Start Menu group missing ($smGroup)" } + # ---- an upgrade whose new service will not start (waired-agent#1181, + # and the rollback half of the ruling in + # docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md) + # + # The pre-flight answers "will these programs run here". It cannot + # answer "will they still be allowed to run in thirty seconds", and on a + # Smart App Control host the verdict does move on its own. So the + # upgrade path keeps a copy of the waired-agent.exe it replaces, and an + # upgrade that cannot bring the service up puts it back. + # + # where.exe is the stand-in the #1087 asserts use: a real, + # Microsoft-signed image that starts (so the pre-flight passes) and + # exits immediately (so the SCM never sees a service come up). + # + # Do NOT read its exit code as "the command failed": where.exe exits 0 + # when it finds something on PATH, so `waired-agent.exe start` becomes + # `where.exe start`, which answered 0 on this runner and 1 on a + # developer machine. That is why the installer confirms the service is + # running with the SCM instead of trusting what `start` reported -- + # and why this stand-in fails it either way. + $msPath = Join-Path ${env:ProgramFiles} 'ClaudeCode\managed-settings.json' + $msBefore = if (Test-Path -LiteralPath $msPath) { Get-Content -Raw -LiteralPath $msPath } else { $null } + $goodAgent = Join-Path $Work 'iss-good-agent.exe' + Copy-Item -LiteralPath (Join-Path $distDir 'waired-agent.exe') -Destination $goodAgent -Force + + # Build one deliberately broken payload and run it. /DNoCompression: + # these builds are thrown away after one run, and lzma2/ultra over + # ~50 MB costs about a minute each. WaitForExit with a deadline rather + # than -Wait: a Setup stopped on a dialog no /SUPPRESSMSGBOXES answered + # would otherwise hang this leg until the job timeout and say nothing. + function Invoke-BrokenExeInstall { + param([string]$Label, [scriptblock]$Doctor) + & $Doctor + & $iscc "/DAppVersion=$Label" '/DNoCompression' (Join-Path $Root 'packaging\windows\waired-setup.iss') | + Select-Object -Last 2 | Out-Host + if ($LASTEXITCODE -ne 0) { ItDie "ISCC exited $LASTEXITCODE building the $Label payload" } + $badSetup = Join-Path $Root "dist\WairedSetup-$Label-x64.exe" + $badLog = Join-Path $Work "innosetup-$Label.log" + $bp = Start-Process -FilePath $badSetup ` + -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/LOG=$badLog" -PassThru + $exited = $bp.WaitForExit(180000) + Copy-Item -LiteralPath $goodAgent -Destination (Join-Path $distDir 'waired-agent.exe') -Force + Remove-Item -LiteralPath $badSetup -Force -ErrorAction SilentlyContinue + if (-not $exited) { + $bp.Kill() + ItBad "$Label : WairedSetup did not exit within 180s (a message box no /SUPPRESSMSGBOXES answered?)" + return [pscustomobject]@{ ExitCode = $null; Log = ''; LogPath = $badLog } + } + return [pscustomobject]@{ + ExitCode = $bp.ExitCode + Log = if (Test-Path -LiteralPath $badLog) { Get-Content -Raw -LiteralPath $badLog } else { '' } + LogPath = $badLog + } + } + + ItStep "ExeVariant: an upgrade whose service will not start puts the previous one back (#1181)" + $agentPath = Join-Path $InstallDir 'waired-agent.exe' + $agentBefore = (Get-FileHash -LiteralPath $agentPath -Algorithm SHA256).Hash + $r = Invoke-BrokenExeInstall -Label 'wont-start-on-upgrade' -Doctor { + Copy-Item -LiteralPath (Join-Path $env:SystemRoot 'System32\where.exe') ` + -Destination (Join-Path $distDir 'waired-agent.exe') -Force + } + if ($r.ExitCode -ne 0) { ItOk "the upgrade fails ($($r.ExitCode))" } else { ItBad "the upgrade reported success" } + if ($r.Log -match 'background service did not start') { ItOk "it says what stopped it" } + else { ItBad "the log does not say what stopped it (see $($r.LogPath))" } + $svcAfter = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + for ($i = 0; $i -lt 20 -and $svcAfter -and $svcAfter.Status -ne 'Running'; $i++) { Start-Sleep 1; $svcAfter.Refresh() } + if ($svcAfter -and $svcAfter.Status -eq 'Running') { ItOk "the service is Running again after the rollback" } + else { ItBad "the service did not come back after a refused upgrade (status=$($svcAfter.Status))" } + if ((Get-FileHash -LiteralPath $agentPath -Algorithm SHA256).Hash -eq $agentBefore) { + ItOk "waired-agent.exe is byte-for-byte the one that was there" + } else { ItBad "waired-agent.exe was left as the one that could not start" } + $msAfter = if (Test-Path -LiteralPath $msPath) { Get-Content -Raw -LiteralPath $msPath } else { $null } + if ($msAfter -eq $msBefore) { ItOk "Claude Code was not touched by the refused upgrade" } + else { ItBad "Claude Code's managed settings changed on a refused upgrade" } + Get-ChildItem -LiteralPath $InstallDir -Filter '*.displaced-*' -File -ErrorAction SilentlyContinue | + ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } + ItStep "ExeVariant: uninstall (unins000.exe /VERYSILENT)" # Bounded by POLLING, not -Wait: the Inno uninstaller re-spawns itself # as a %TEMP% _iu*.tmp copy (the original exe exits early), and @@ -4097,6 +4177,67 @@ if ($ExeVariant) { # sweep the residue — the guest is disposable. Remove-Item -LiteralPath $StateDir, $InstallDir -Recurse -Force -ErrorAction SilentlyContinue if (-not (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue)) { ItOk "service gone after Inno uninstall" } else { ItBad "service survived the Inno uninstall" } + + # ---- a fresh install that cannot bring the service up (#1181) ---- + # + # The reported host had Smart App Control on: the GUI installer's + # service-registration step was refused (CreateProcess 4551), Inno + # logged it and carried on, `waired claude enable` ran anyway, and Setup + # reported success. Claude Code was then pointed at a gateway that would + # never listen and every turn failed with ConnectionRefused. + # + # A policy refusal cannot be produced on demand -- a hosted runner has + # no Smart App Control, and where there is one the verdict is + # non-deterministic (measured 2026-09-03 on a Windows 11 host with SAC + # on: it refused a freshly compiled WairedSetup.exe itself). What the + # installer sees is identical either way -- a program Windows will not + # start, or one that starts and leaves no running service -- so these + # two payloads produce it deterministically. + # + # Both runs must leave the computer exactly as they found it, and + # neither may touch Claude Code. Note the ABSENCE of + # /MERGETASKS=!claudeproxy in Invoke-BrokenExeInstall: the claudeproxy + # task is left at its default (on), so "no managed-settings.json + # afterwards" means Setup stopped before the integration and not that + # the task was unchecked. + function Assert-NothingInstalled { + param([string]$Label, $Result, [string]$WantInLog) + if ($Result.ExitCode -ne 0) { ItOk "$Label : the install fails ($($Result.ExitCode))" } + else { ItBad "$Label : the install reported success" } + if ($Result.Log -match $WantInLog) { ItOk "$Label : it says what stopped it" } + else { ItBad "$Label : the log does not say what stopped it (want /$WantInLog/, see $($Result.LogPath))" } + if (-not (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue)) { ItOk "$Label : no service was left registered" } + else { ItBad "$Label : a waired-agent service was left registered" } + $left = @(Get-ChildItem -LiteralPath $InstallDir -Force -ErrorAction SilentlyContinue | Select-Object -Expand Name) + if (-not $left) { ItOk "$Label : nothing was left in $InstallDir" } + else { ItBad "$Label : left behind $($left -join ', ') in $InstallDir" } + $after = if (Test-Path -LiteralPath $msPath) { Get-Content -Raw -LiteralPath $msPath } else { $null } + if ($after -eq $msBefore) { ItOk "$Label : Claude Code was not touched (#1181)" } + else { ItBad "$Label : Claude Code's managed settings changed on a failed install (#1181 regression)" } + if (-not (Test-Path -LiteralPath 'HKLM:\SOFTWARE\Waired')) { ItOk "$Label : no install-location key was left in the registry" } + else { ItBad "$Label : HKLM\SOFTWARE\Waired survived a failed install" } + } + + # Re-read the baseline: the uninstall above removed the managed settings + # the good install wrote, so the copy taken before the upgrade case is + # no longer what "unchanged" means here. + $msBefore = if (Test-Path -LiteralPath $msPath) { Get-Content -Raw -LiteralPath $msPath } else { $null } + + # (1) Windows will not start the new waired-agent.exe. + ItStep "ExeVariant: a fresh install refuses a program that will not run (#1181)" + Assert-NothingInstalled -Label 'will-not-run' -WantInLog 'will not run the new waired-agent\.exe' ` + -Result (Invoke-BrokenExeInstall -Label 'will-not-run' -Doctor { + Set-Content -LiteralPath (Join-Path $distDir 'waired-agent.exe') -Value 'not a program' -NoNewline + }) + + # (2) It starts, so the pre-flight passes, and the SERVICE is what never + # comes up. + ItStep "ExeVariant: a fresh install refuses when the service will not start (#1181)" + Assert-NothingInstalled -Label 'service-wont-start' -WantInLog 'background service did not start' ` + -Result (Invoke-BrokenExeInstall -Label 'service-wont-start' -Doctor { + Copy-Item -LiteralPath (Join-Path $env:SystemRoot 'System32\where.exe') ` + -Destination (Join-Path $distDir 'waired-agent.exe') -Force + }) } catch { ItBad "ExeVariant threw: $($_.Exception.Message)" @@ -4688,7 +4829,16 @@ if ($Tier -ge 2) { # pinning 153 would make any legitimately conditional assert elsewhere in # the leg a spurious red. Raise it by what an addition always contributes, # which is what this file has asked for since #505. - $floor = if ($Contract) { 139 } elseif ($EngineOnly) { 80 } else { 77 } + # + # 139 -> 156 for waired-agent#1181: seventeen asserts that always run, in + # the ExeVariant block. Five for the upgrade whose service will not start + # (it fails / it says so / the service is Running again / waired-agent.exe + # is byte-for-byte the previous one / Claude Code untouched) and six each + # for the two fresh installs that decline. The positive path swapped one + # assert for another and contributes nothing. Measured on run 33786027609, + # where this leg executed 187 with no failures -- 156, not 187, for the + # reason in the paragraph above. + $floor = if ($Contract) { 156 } elseif ($EngineOnly) { 80 } else { 77 } if ($executed -lt $floor) { Write-Host ("[installtest] FAIL only {0} asserts ran at tier {1}; at least {2} must (a block stopped executing -- see the assert-count floor in installtest-windows.ps1)" -f $executed, $Tier, $floor) -ForegroundColor Red exit 1 diff --git a/scripts/dev/testdata/sac-signing-inventory.txt b/scripts/dev/testdata/sac-signing-inventory.txt index 2e724a3c3..7d5dae95c 100644 --- a/scripts/dev/testdata/sac-signing-inventory.txt +++ b/scripts/dev/testdata/sac-signing-inventory.txt @@ -37,6 +37,13 @@ # directory, so Get-SacInventoryKey buckets those loads under ProgramFiles # with the same file names. Move staging outside Program Files and this list # grows a Temp/ (or Other/) row for each program. +# +# STILL TRUE AFTER waired-agent#1181: the Windows GUI installer now does the +# same thing -- packaging/windows/waired-setup.iss runs the three programs from +# {app}\.waired-staging before it installs anything -- and it stages there for +# this reason among others. It extracts to {tmp} first but never executes from +# there, so Code Integrity sees no Temp/ load. (-SacAudit does not drive the +# GUI installer today; this note is for whoever makes it.) ProgramFiles/waired-agent.exe ProgramFiles/waired-tray.exe diff --git a/scripts/install/waired_setup_iss_test.go b/scripts/install/waired_setup_iss_test.go new file mode 100644 index 000000000..5e8160d4a --- /dev/null +++ b/scripts/install/waired_setup_iss_test.go @@ -0,0 +1,354 @@ +package installscripts + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The Windows GUI installer is an Inno Setup script, and until waired-agent#1181 +// nothing in CI read it: install-script-lint is shellcheck over the .sh files, +// ps-script-lint covers the .ps1 files, and the install test's ExeVariant leg +// only observes the effects of a run on a Windows runner. So a change to +// waired-setup.iss could move where a program is executed from -- which decides +// whether a failure can still fail the installation -- and no check would +// notice until someone read the file. +// +// These tests are a record of the shape #1181 landed on, not a style rule. What +// they pin, and why each one matters, is on each test. +const issRel = "packaging/windows/waired-setup.iss" + +func readISS(t *testing.T) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(repoRoot(t), filepath.FromSlash(issRel))) + if err != nil { + t.Fatalf("read %s: %v", issRel, err) + } + return string(b) +} + +// section returns the body of one [Section] of an Inno Setup script: every line +// after the header up to the next one. Continuation lines (a trailing "\") are +// joined, because Inno treats them as one entry and so must anything reading +// them. +func section(t *testing.T, iss, name string) []string { + t.Helper() + var ( + out []string + in bool + joint string + ) + header := regexp.MustCompile(`^\[[A-Za-z]+\]\s*$`) + for _, raw := range strings.Split(iss, "\n") { + line := strings.TrimRight(raw, "\r") + if header.MatchString(strings.TrimSpace(line)) { + in = strings.EqualFold(strings.TrimSpace(line), "["+name+"]") + continue + } + if !in { + continue + } + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, ";") { + continue + } + if strings.HasSuffix(trimmed, `\`) { + joint += strings.TrimSpace(strings.TrimSuffix(trimmed, `\`)) + " " + continue + } + out = append(out, strings.TrimSpace(joint+trimmed)) + joint = "" + } + if len(out) == 0 { + t.Fatalf("[%s] section of %s is empty -- the parser or the file changed shape", name, issRel) + } + return out +} + +// entryField pulls one "Name: value" field out of an Inno section entry. +// Values are quoted in this script; both forms are accepted so a future +// unquoted value is read rather than silently dropped. +func entryField(entry, field string) string { + re := regexp.MustCompile(`(?i)\b` + field + `:\s*("([^"]*)"|[^;]*)`) + m := re.FindStringSubmatch(entry) + if m == nil { + return "" + } + if m[2] != "" || strings.HasPrefix(strings.TrimSpace(m[1]), `"`) { + return m[2] + } + return strings.TrimSpace(m[1]) +} + +// TestSetupRunsOnlyTheseProgramsFromRunSections pins what the installer +// executes from [Run] and [UninstallRun]. +// +// The point is the emptiness of [Run], not its contents. Inno processes [Run] +// after the install stage has been committed and discards the result +// (Setup.MainForm.pas ProcessRunEntries), so a program that fails there leaves +// Setup reporting success -- which is exactly what #1181 was: a blocked +// `waired-agent.exe install` was logged and stepped over, and the Claude Code +// integration ran anyway. Anything that must be able to fail the install +// belongs in [Code], reached from a [Files] AfterInstall. +func TestSetupRunsOnlyTheseProgramsFromRunSections(t *testing.T) { + iss := readISS(t) + + want := map[string][]string{ + "Run": { + // The tray, launched from the finish page. Nothing else: a [Run] + // entry cannot fail the installation. + `{app}\waired-tray.exe `, + }, + "UninstallRun": { + `{app}\waired.exe claude disable`, + `{app}\waired-agent.exe uninstall`, + }, + } + for _, name := range []string{"Run", "UninstallRun"} { + var got []string + for _, entry := range section(t, iss, name) { + got = append(got, entryField(entry, "Filename")+" "+entryField(entry, "Parameters")) + } + sort.Strings(got) + expect := append([]string(nil), want[name]...) + sort.Strings(expect) + if strings.Join(got, "\n") != strings.Join(expect, "\n") { + t.Errorf("[%s] runs a different set of programs than expected\n got: %q\nwant: %q", name, got, expect) + } + } +} + +// TestCodeRunsOnlyTheseProgramsDuringSetup pins the programs [Code] executes. +// Every one of them goes through RunInstalledProgram, whose whole job is to +// report a program Windows would not start (Exec returning False) as loudly as +// one that ran and failed -- the two were indistinguishable before #1181 +// because neither was looked at. +func TestCodeRunsOnlyTheseProgramsDuringSetup(t *testing.T) { + iss := readISS(t) + + // The program name is sometimes a string constant, so resolve the script's + // own `const Name = 'value';` lines before matching the call sites. + consts := map[string]string{} + for _, m := range regexp.MustCompile(`(?m)^\s*([A-Za-z]\w*)\s*=\s*'([^']*)';`).FindAllStringSubmatch(iss, -1) { + consts[m[1]] = m[2] + } + re := regexp.MustCompile(`RunInstalledProgram\((?:'([^']+)'|([A-Za-z]\w*)),\s*'([^']*)'\)`) + seen := map[string]bool{} + for _, m := range re.FindAllStringSubmatch(iss, -1) { + name := m[1] + if name == "" { + var ok bool + if name, ok = consts[m[2]]; !ok { + t.Fatalf("RunInstalledProgram is called with %s, which this guard cannot resolve to a program name", m[2]) + } + } + seen[name+" "+m[3]] = true + } + want := []string{ + "waired-agent.exe install", // fresh install: register the service + "waired-agent.exe start", // and bring it up; its exit code is the answer + "waired-agent.exe stop", // upgrade: stop the old one before replacing it + "waired-agent.exe uninstall", // a fresh install that failed leaves no half-registered service + "waired.exe claude enable", // last, and only with a running daemon + } + var got []string + for k := range seen { + got = append(got, k) + } + sort.Strings(got) + sort.Strings(want) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("[Code] runs a different set of programs than expected\n got: %q\nwant: %q", got, want) + } +} + +// TestClaudeCodeIsTouchedOnlyAfterTheServiceIsRunning is the #1181 defect +// itself: Setup pointed Claude Code at a gateway that would never listen, +// because the integration step did not depend on the service existing. +func TestClaudeCodeIsTouchedOnlyAfterTheServiceIsRunning(t *testing.T) { + iss := readISS(t) + + enable := strings.Index(iss, `RunInstalledProgram('waired.exe', 'claude enable')`) + if enable < 0 { + t.Fatal("the Claude Code integration step is gone: GUI installs would be left unrouted") + } + guard := strings.Index(iss, "if not gAgentRunning then") + if guard < 0 { + t.Fatal("nothing checks gAgentRunning before the Claude Code integration (#1181)") + } + if guard > enable { + t.Error("the Claude Code integration runs before the service is checked (#1181)") + } + if !strings.Contains(iss, "gAgentRunning := True") { + t.Error("gAgentRunning is never set: the integration would never run") + } +} + +// TestProgramsAreEmbeddedOnceAndTriedBeforeTheyAreInstalled pins the +// arrangement that lets Setup try the programs before it installs them: all +// three embedded once with dontcopy, so PrepareToInstall can extract and run +// them; the two Inno installs taken from those same extracted copies with +// external, so there is no second ~50 MB copy in the setup executable and no +// chance of installing bytes nobody tried. +// +// waired-agent.exe is the exception, and it has to be: PrepareToInstall puts it +// in place and starts its service, because nothing after PrepareToInstall can +// still decline (see the .iss header). A [Files] entry for it would overwrite +// the running service's own image straight afterwards. +func TestProgramsAreEmbeddedOnceAndTriedBeforeTheyAreInstalled(t *testing.T) { + iss := readISS(t) + entries := section(t, iss, "Files") + + for _, prog := range []string{"waired.exe", "waired-agent.exe", "waired-tray.exe"} { + var embedded, byInno bool + for _, e := range entries { + switch entryField(e, "Source") { + case `dist\windows-amd64\` + prog: + embedded = strings.Contains(entryField(e, "Flags"), "dontcopy") + case `{tmp}\` + prog: + byInno = strings.Contains(entryField(e, "Flags"), "external") && entryField(e, "DestDir") == "{app}" + } + } + if !embedded { + t.Errorf("%s is not embedded with dontcopy: PrepareToInstall cannot try it before installing it", prog) + } + wantByInno := prog != "waired-agent.exe" + if byInno == wantByInno { + continue + } + if wantByInno { + t.Errorf("%s is not installed from the checked copy in {tmp} with external", prog) + } else { + t.Errorf("%s is installed by [Files]; PrepareToInstall places it, and [Files] would overwrite the running service's image", prog) + } + } + + // Whatever Inno does not install, the uninstaller has to be told about. + del := strings.Join(section(t, iss, "UninstallDelete"), "\n") + for _, name := range []string{`{app}\waired-agent.exe`, `{app}\waired-agent.exe.displaced-*`} { + if !strings.Contains(del, name) { + t.Errorf("[UninstallDelete] does not remove %s, and Inno's uninstall log does not know it either", name) + } + } +} + +// TestNothingHangsOffAnAfterInstall keeps work out of the hooks that cannot fail +// an installation. Inno catches exceptions from Before/AfterInstall on purpose +// -- "Don't allow exceptions raised by Before/AfterInstall functions to be +// propagated out", Setup.MainFunc.pas NotifyInstallEntry -- so a step placed +// there reports its failure to nobody, which is the shape of #1181. Measured on +// Windows 11 with Inno Setup 6.7.3: an AfterInstall that raised left Setup +// exiting 0 with everything installed. +func TestNothingHangsOffAnAfterInstall(t *testing.T) { + iss := readISS(t) + for _, sec := range []string{"Files", "Run", "UninstallRun"} { + for i, e := range section(t, iss, sec) { + for _, field := range []string{"AfterInstall", "BeforeInstall"} { + if got := entryField(e, field); got != "" { + t.Errorf("[%s] entry %d has %s: %q -- that hook cannot fail an install", sec, i, field, got) + } + } + } + } + if !strings.Contains(iss, "function PrepareToInstall(") { + t.Error("PrepareToInstall is gone: nothing left in this script can decline an install") + } +} + +// TestPrepareToInstallIsWhereTheDecidingHappens keeps the two things that can +// decline an install wired into the one hook that can act on them. Leaving +// either call out is invisible otherwise: the functions stay in the file, the +// script still compiles, and Setup goes back to installing whatever it is +// handed -- which is #1181. +func TestPrepareToInstallIsWhereTheDecidingHappens(t *testing.T) { + iss := readISS(t) + + start := strings.Index(iss, "function PrepareToInstall(") + if start < 0 { + t.Fatal("PrepareToInstall is gone: nothing left in this script can decline an install") + } + end := strings.Index(iss[start:], "\nend;") + if end < 0 { + t.Fatal("could not find the end of PrepareToInstall -- this guard stopped reading it") + } + body := iss[start : start+end] + + for _, call := range []string{"CheckProgramsRunHere()", "SetUpTheService()"} { + if !strings.Contains(body, call) { + t.Errorf("PrepareToInstall does not call %s, so its failure can no longer stop the install", call) + } + } +} + +// TestStagedChecksMatchInstallPS1 holds the GUI installer's pre-flight table +// against the PowerShell installer's. They are two copies of one decision +// (docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md) +// and would otherwise drift: a computer would be refused by one installer and +// accepted by the other, which is worse than either answer. +func TestStagedChecksMatchInstallPS1(t *testing.T) { + root := repoRoot(t) + iss := readISS(t) + + issRe := regexp.MustCompile( + `if Name = '([^']+)'\s+then begin Params := '([^']*)';\s+RequireZeroExit := (True|False);\s+Fatal := (True|False);\s+end;`) + fromISS := map[string]string{} + for _, m := range issRe.FindAllStringSubmatch(iss, -1) { + fromISS[m[1]] = fmt.Sprintf("args=%q zero=%s fatal=%s", + m[2], strings.ToLower(m[3]), strings.ToLower(m[4])) + } + if len(fromISS) != 3 { + t.Fatalf("read %d checks out of %s, want 3 -- StagedCheck changed shape and this guard stopped reading it", + len(fromISS), issRel) + } + + const ps1Rel = "packaging/install/install.ps1" + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(ps1Rel))) + if err != nil { + t.Fatalf("read %s: %v", ps1Rel, err) + } + ps1Re := regexp.MustCompile( + `@\{\s*Name\s*=\s*'([^']+)';\s*Arguments\s*=\s*@\(([^)]*)\);\s*RequireZeroExit\s*=\s*\$(true|false);\s*Fatal\s*=\s*\$(true|false)\s*\}`) + fromPS1 := map[string]string{} + for _, m := range ps1Re.FindAllStringSubmatch(string(b), -1) { + var args []string + for _, a := range strings.Split(m[2], ",") { + args = append(args, strings.Trim(strings.TrimSpace(a), "'")) + } + fromPS1[m[1]] = fmt.Sprintf("args=%q zero=%s fatal=%s", + strings.Join(args, " "), strings.ToLower(m[3]), strings.ToLower(m[4])) + } + if len(fromPS1) != 3 { + t.Fatalf("read %d checks out of %s, want 3 -- Get-StagedBinaryChecks changed shape and this guard stopped reading it", + len(fromPS1), ps1Rel) + } + + for name, want := range fromPS1 { + got, ok := fromISS[name] + if !ok { + t.Errorf("%s checks %s before installing it; %s does not", ps1Rel, name, issRel) + continue + } + if got != want { + t.Errorf("%s asks %s differently than %s does\n .iss: %s\n .ps1: %s", issRel, name, ps1Rel, got, want) + } + } + for name := range fromISS { + if _, ok := fromPS1[name]; !ok { + t.Errorf("%s checks %s before installing it; %s does not", issRel, name, ps1Rel) + } + } +} + +// TestSetupAlwaysWritesALog pins SetupLogging. #1181 could only be diagnosed -- +// "CreateProcess failed; code 4551." twice, then Setup carrying on -- because +// that run happened to have a log. Without this directive only a caller that +// passes /LOG gets one, which is never the person hitting the bug. +func TestSetupAlwaysWritesALog(t *testing.T) { + if !regexp.MustCompile(`(?mi)^SetupLogging\s*=\s*yes\s*$`).MatchString(readISS(t)) { + t.Errorf("%s does not set SetupLogging=yes: a failed install leaves nothing to read", issRel) + } +}