Skip to content

installer/windows: the GUI installer does not report success when it installed nothing (#1181) - #1199

Merged
gen16k merged 4 commits into
mainfrom
fix/1181-gui-installer-refuses-programs-that-cannot-run
Sep 3, 2026
Merged

gen16k merged 4 commits into
mainfrom
fix/1181-gui-installer-refuses-programs-that-cannot-run

Conversation

@gen16k

@gen16k gen16k commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What this is about

On a Windows host where Smart App Control refused waired-agent.exe,
WairedSetup-0.0.3-rc5-x64.exe /VERYSILENT recorded
CreateProcess failed; code 4551. twice in its log, carried on, ran
waired.exe claude enable, and finished as a successful install with exit
code 0. The host was left with no service and a Claude Code pointed at a
gateway that would never listen — every turn failed with
API Error: Unable to connect to API (ConnectionRefused).

The ruling this breaks is already on file:
docs/decisions/20260829/1730-installer-refuses-programs-that-cannot-run.md
(owner, 2026-08-29) — 「インストールでも更新でも、waired.exe
waired-agent.exe のどちらかがこの機で実行できないなら、そこで止める」
.
install.ps1 implements it (#1153). The GUI installer never did, and
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."

Fixes #1181
Refs waired-ai/waired#1312 (L96)

What I found first: an Inno installation can only be declined in one place

I went in expecting to raise from a [Files] AfterInstall. That does not work,
and neither do the other two candidates. Measured against the Inno Setup 6
sources (jrsoftware/issrc, tag is-6_7_3) and confirmed on a Windows 11 host
with Inno Setup 6.7.3:

hook can it fail the installation? why
PrepareToInstall returns a message yes — exit code 7, before [Files], before the service is stopped, before Claude Code is looked at Setup.WizardForm.pas ClickThroughPages
[Files] AfterInstall / BeforeInstall raises no — Inno swallows it deliberately: "Don't allow exceptions raised by Before/AfterInstall functions to be propagated out" Setup.MainFunc.pas NotifyInstallEntry
[Run] entry fails no — the result is discarded Setup.MainForm.pas ProcessRunEntries
CurStepChanged(ssPostInstall) raises noSetStep(ssPostInstall, True) handles it and carries on Setup.MainForm.pas SetStep
[Files] Check raises fails, but unusable — evaluated twice (Ready page CalcFilesSize, then CopyFiles) Setup.Install.HelperFunc.pas

I measured the AfterInstall row rather than trusting the reading: a build
that raised there exited 0 with everything installed. The whole design
follows from that row, and it is written down in
docs/knowledges/20260904/0210-inno-can-only-decline-before-it-installs.md.

Also measured, and load-bearing: Inno routes Application.HandleException
through a suppressible box, so an exception cannot hang a
/VERYSILENT /SUPPRESSMSGBOXES run (the waired#760 hazard does not apply
here); and rollback keeps files that existed before the install
(utDeleteFile_ExistedBeforeInstall), so a restored binary survives it.

What changed

packaging/windows/waired-setup.iss — everything that can fail now happens
in PrepareToInstall:

  • The three programs are embedded once with dontcopy, extracted, copied
    into {app}\.waired-staging and actually run there before anything on
    the computer is created, stopped or replaced. The table of what to ask each
    one is install.ps1's Get-StagedBinaryChecks, unchanged: waired.exe version --json (exit code read) and waired-agent.exe -h are fatal, the
    Waired app only warns. Staging sits under the install directory for the same
    reason install.ps1 puts it there. The two Inno installs are external
    entries reading those extracted copies, so the setup executable carries one
    copy, not two.
  • waired-agent.exe is placed by Setup itself, and its service registered,
    started and confirmed Running — inside PrepareToInstall, because that is
    the last moment Setup can still decline. [UninstallDelete] removes it.
    waired-agent.exe start already waits for Running and exits non-zero
    otherwise, so its exit code is the answer and no localised sc query output
    is parsed.
  • An upgrade keeps the waired-agent.exe it replaces and puts it back,
    restarting the service, when the new one will not come up — the rollback half
    of the ruling, for the case the pre-flight cannot cover (the verdict moves on
    its own).
  • Claude Code is touched last, and only with a running daemon. The
    claude enable step moved out of [Run] into ssPostInstall behind
    gAgentRunning, so it cannot be reached by a computer with no service. When
    it fails on its own it is not fatal — Claude Code keeps talking to
    api.anthropic.com, which is where it was — but it is no longer silent.
  • SetupLogging=yes. WairedSetup: the GUI installer reports success and enables the Claude Code integration although the agent service was never installed — leaving Claude Code broken #1181 was only diagnosable because that run happened to
    have a log; a person hitting this never passes /LOG.
  • AllowCancelDuringInstall=no (by then the service is up and the remaining
    work is a few seconds of copying), and the dead WipeStatePage variable is
    gone.

Tests. scripts/install/waired_setup_iss_test.go is the first check in the
repo that reads the .iss at all — install-script-lint is shellcheck,
ps-script-lint covers the .ps1 files, ARGTEST is install.ps1-only. It
pins, by set equality: what Setup executes; that nothing hangs off a hook that
cannot fail; that the programs are embedded once and tried before they are
installed; that claude enable sits behind the service check; and it holds the
pre-flight table against install.ps1's, because two copies of one table
forget different things. Each assertion was checked to fail on the defect it
names.

The install test's -ExeVariant leg (every PR) gains three behaviours, driven
by deliberately broken payloads — a policy refusal cannot be produced on demand
— the same technique the #1087 asserts use for the update path:

  • a fresh install whose waired-agent.exe will not run,
  • a fresh install whose service will not start (where.exe stand-in),
  • an upgrade whose service will not start, asserting the previous
    waired-agent.exe is back byte-for-byte and the service Running.

Those runs deliberately do not pass /MERGETASKS=!claudeproxy, so "no
managed-settings.json afterwards" means Setup stopped before the integration
rather than that the task was unchecked. They use a new build-time
/DNoCompression switch so a throwaway payload does not cost a minute of
lzma2/ultra; shipping builds never pass it. Each run is bounded by
WaitForExit(180s) so a Setup stopped on a dialog fails this leg in three
minutes with a clear message instead of hanging it. The positive path now
asserts the service is already Running when the installer returns, instead
of starting it by hand — the harness comment claiming a fresh Inno install does
not start the service was stale.

scripts/ci/docs-surface-guard.sh: packaging/windows/ joins the list.
The GUI installer was the one install path outside it — packaging/install/
has been there from the start — and that gap is why update.mdx could promise
a behaviour the GUI installer did not have with nothing failing.

Docs: a new "If Windows refuses to run a program" section on the Windows
install page (EN + ja), quoting the product output verbatim, which makes
update.mdx's "a fresh install behaves the same way" checkable.

Verification on real hardware

Windows 11, Inno Setup 6.7.3, from a genuinely clean machine, all four paths:

run exit after
fresh, waired-agent.exe will not run 7 %ProgramFiles%\Waired empty, no service, no registry key, no Start Menu, Claude Code untouched
fresh, service will not start 7 same — including the waired-agent.exe Setup had placed
fresh, good 0 service already Running when the installer returned, start mode Auto, managed-settings.json written
upgrade, new agent's service will not start 7 service Running, waired-agent.exe byte-for-byte the one that was there (9368638…), managed-settings.json unchanged, no .displaced-* left

Two things worth recording from the same session:

  • On a Windows 11 host with Smart App Control on, a freshly compiled
    unsigned WairedSetup.exe was refused outright (CodeIntegrity 3077 + 3033 +
    3118, policy {0283ac0f-…}), so the GUI installer would not start at all
    there. That is why the functional verification ran on a host with SAC off and
    why the CI cases use broken payloads. The event records went to installer(windows): find out what makes Smart App Control refuse a Waired program, and what makes the refusal lift #1191.
  • install.ps1's Checking the new programs run on this computer before replacing anything is now printed by the GUI installer too, so docs-site can
    quote one line for both.

Local: gofmt, go build ./..., go test ./scripts/..., golangci-lint run --max-same-issues=0 (0 issues), install-script-lint, docs-surface-guard
(fires and is satisfied), npm run i18n:check (32 pairs in sync).

Known follow-up

The assert-count floor in scripts/dev/installtest-windows.ps1
($floor = if ($Contract) { 139 }) has to be re-measured from a green run of
this branch rather than estimated; I will update it from the first CI run
before merging.

🤖 Generated with Claude Code

https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2

…installed nothing (#1181)

WairedSetup ran `waired-agent.exe install` as a [Run] entry, and Inno
discards a [Run] entry's result. On a host where Smart App Control
refused that program the Inno log recorded `CreateProcess failed; code
4551.` twice, Setup carried on, `waired.exe claude enable` ran anyway,
and the wizard finished with exit code 0. The computer was left with no
service and a Claude Code pointed at a gateway that would never listen:
every turn failed with `API Error: Unable to connect to API
(ConnectionRefused)`.

The ruling this breaks is already on file — "インストールでも更新でも、
waired.exe か waired-agent.exe のどちらかがこの機で実行できないなら、
そこで止める" (docs/decisions/20260829/1730). install.ps1 implements it
(#1153); the GUI installer never did, and update.mdx already promised
"A fresh install behaves the same way".

Where a failure can still fail an Inno installation turned out to be one
place, not three. Measured against the Inno Setup 6 sources and on a
Windows 11 host: [Run] results are discarded, ssPostInstall exceptions
are handled and stepped over, and Before/AfterInstall exceptions are
swallowed on purpose ("Don't allow exceptions raised by
Before/AfterInstall functions to be propagated out"). Only
PrepareToInstall can decline — exit code 7, before anything is created,
stopped or replaced, and silent-safe. So everything that can fail now
happens there:

- the three programs are embedded once (dontcopy), extracted, and RUN
  from a staging directory under the install directory before anything
  is touched, with the same table install.ps1 uses;
- waired-agent.exe is placed by Setup itself and its service registered,
  started and confirmed Running — that is the last moment Setup can
  still decline, so it cannot be left to [Files];
- an upgrade keeps the waired-agent.exe it replaces and puts it back,
  restarting the service, when the new one will not come up;
- Claude Code is touched last, and only with a running daemon.

SetupLogging=yes so a failed install always leaves a log — #1181 was
only diagnosable because that run happened to have one.

Verified on real hardware (Windows 11, Inno Setup 6.7.3), four paths:
a fresh install whose agent will not start and one whose service will
not come up both exit 7 with an empty %ProgramFiles%\Waired, no service,
no registry key and Claude Code untouched; a good fresh install exits 0
with the service already Running when the installer returns; an upgrade
whose new agent will not start exits 7 with the service Running and
waired-agent.exe byte-for-byte the one that was there.

Tests: scripts/install/waired_setup_iss_test.go is the first check that
reads the .iss at all — it pins what Setup executes, that nothing hangs
off a hook that cannot fail, and holds the pre-flight table against
install.ps1's. The install test's ExeVariant leg gains the three
behaviours above, driven by deliberately broken payloads because a
policy refusal cannot be produced on demand.

packaging/windows/ joins docs-surface-guard's list: the GUI installer
was the one install path outside it.

Fixes #1181
Refs waired-ai/waired#1312

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

📘 Docs preview — the preview channel for this PR has been deleted now that it is closed.

… again

- The install test compared managed-settings.json against a copy taken
  before the uninstall that removes it, so the two fresh-install cases
  would have failed on a correct installer. Re-read the baseline after
  the uninstall, where "unchanged" means something else.

- The .iss guard read the call sites but not the wiring: dropping
  `SetUpTheService()` out of PrepareToInstall left the functions in the
  file, the script compiling, and every assertion green. Pin the two
  calls PrepareToInstall has to make.

Refs #1181

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
gen16k and others added 2 commits September 4, 2026 02:41
…ith what start reported

The Windows install test caught the gap on its first run. `waired-agent.exe
start` waits for Running and exits non-zero otherwise, so its exit code
should be the answer — but the installer does not know that the program it
just placed is really waired-agent. With `where.exe` standing in for it, the
runner's PATH had something matching `start`, `where.exe start` exited 0, and
Setup reported a successful upgrade over a service that was Stopped. The same
stand-in exits 1 on a developer machine: where.exe's exit code depends on what
is on PATH.

So ask the SCM instead of trusting the report: `sc.exe interrogate
waired-agent` answers 0 when the service is running, 1062 when it is
registered but stopped, 1060 when it is not registered — exit codes only, so
nothing parses localised `sc query` output.

Measured with a Go stub that exits 0 for every argument, so `install` and
`start` both "succeed": Setup now stops with "the service is registered but is
not running", exits 7, and puts the previous waired-agent.exe back with the
service Running (hash unchanged).

The remaining Windows failures in that run were downstream of this one: the
upgrade left where.exe as waired-agent.exe, so the uninstaller's
`waired-agent.exe uninstall` never deregistered the service, the Inno
uninstall timed out waiting for it, and every later assert saw a service that
should not have existed. main's Windows leg has no failures.

Refs #1181

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
Seventeen asserts that always run were added with the GUI installer's
refusal cases, so the old floor of 139 no longer means what it says: a
block could stop executing and the leg would still clear it.

Measured, not estimated, from the green run 33786027609 of this exact
configuration (-Tier 2 -Contract -ExeVariant), which executed 187 with
no failures. 156 rather than 187 because the floor is a minimum every
green run must clear, not a pin on today's total.

Also record in the SAC signing inventory that the GUI installer now runs
its programs from the staging directory too, and why that keeps the
ledger's keys under ProgramFiles/ rather than growing a Temp/ row.

Refs #1181

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2
Signed-off-by: gen16k <gen16k@users.noreply.github.com>
@gen16k

gen16k commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Addendum — what CI's first run of this changed, and the measured floor

CI found a hole in the change itself, and closing it made the installer better.

The Windows leg's stand-in for a broken agent is where.exe, the same one the
#1087 asserts use. On the GitHub runner, waired-agent.exe start became
where.exe start, the runner's PATH had something matching start, and
where.exe exited 0 — so Setup believed the report and called the upgrade a
success over a service that was Stopped. The same stand-in exits 1 on a
developer machine: where.exe's exit code depends on what is on PATH. Every
other Windows failure in that run was downstream of this one (the bad upgrade
left where.exe as waired-agent.exe, so waired-agent.exe uninstall never
deregistered the service, the Inno uninstall timed out waiting for it, and each
later assert saw a service that should not have existed). main's Windows leg
has no failures.

The fix is the distinction #1087 is already about — a report is not a state.
waired-agent.exe start does wait for Running and exit non-zero otherwise, but
the installer does not know that the program it just placed is really
waired-agent. So it now asks the SCM: sc.exe interrogate waired-agent, exit
codes only — 0 running, 1062 registered-but-stopped, 1060 not registered — so
nothing parses localised sc query output.

Proven on real hardware with a Go stub that exits 0 for every argument, so
both install and start "succeed":

EXIT=7  svc=Running  interrogate=0  agent 936863804B7B -> 936863804B7B
waired-agent service setup failed: the service is registered but is not running

Declined, previous binary back byte-for-byte, service running again.

Assert floor: raised 139 → 156, measured from the green run
33786027609 of this exact configuration, which executed 187 asserts with no
failures
. 156 rather than 187 because the floor is a minimum every green run
must clear, not a pin on today's total; 17 is what this PR always contributes
(5 for the refused upgrade, 6 each for the two refused fresh installs; the
positive path swapped one assert for another).

All seventeen pass on the runner, including will-not-run : nothing was left in C:\Program Files\Waired and will-not-run : Claude Code was not touched (#1181).

Follow-up filed: #1202 — the setup program ships on every release and every
edge prerelease and is documented nowhere. This PR adds only the one section
it owed (what happens when Windows refuses to run a program); the rest is that
issue.

@gen16k
gen16k merged commit 33434ec into main Sep 3, 2026
20 checks passed
@gen16k
gen16k deleted the fix/1181-gui-installer-refuses-programs-that-cannot-run branch September 3, 2026 18:02
gen16k added a commit that referenced this pull request Sep 3, 2026
… it (#1202)

`WairedSetup-<version>-x64.exe` is attached to every release and every edge
prerelease, and it is a full install path: it elevates, registers the service,
starts it, and turns on the Claude Code integration by default. The site
described one route, the `iwr … | iex` line, and did not name the setup program
anywhere until #1199.

The gap had already produced two wrong promises. packaging/windows/ sat outside
docs-surface-guard's surface list until #1199 added it, so the GUI installer's
behaviour could change with no docs page obliged to keep up — which is how
getting-started/update.mdx came to promise a fresh-install behaviour only
install.ps1 had (#1181). And every release's notes tell people to "see
SmartScreen note in docs" for a note that did not exist; SmartScreen appeared
nowhere under docs-site/.

The new section follows the order a person downloading it actually meets: no
`latest/download/…` shortcut, because the filename carries the version;
"Windows protected your PC" and the way through it, which is why the page still
leads with the PowerShell line; the permission prompt; a wizard that does not
ask where to install and has one checkbox; that it does not run `waired init`,
so signing in is still ahead of you; the log it always writes; in-place upgrade
and uninstall, including that the state directory is kept by default; and the
unattended switches with exit 0 / exit 7.

The checkbox is described rather than quoted: its on-screen text still promises
a transparent fallback to the Anthropic API, which #1198 is retiring, and a
verbatim quote would go stale the day that lands.

Also widen the "What gets installed" Start Menu row — both installers create
**Waired** and **Waired (CLI)**, and the table listed one.

Fixes #1202
Refs #1181, #1199, waired-ai/waired#1312

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2
gen16k added a commit that referenced this pull request Sep 5, 2026
…the install directory is empty (#1181) (#1216)

The refusal cases #1199 added assert that a declined install left nothing
in %ProgramFiles%\Waired. They run after the good install has been
uninstalled, and that uninstall's completion signal is the SERVICE
disappearing — but Inno removes unins000.exe and unins000.dat after that,
from the _iu*.tmp copy it re-spawned as. The one-shot Remove-Item sweep
between the two could land inside that window, fail silently on the two
files because they were still mapped, and leave them for the next case to
report:

    FAIL will-not-run : left behind unins000.dat, unins000.exe in C:\Program Files\Waired

Seen once on run 33793355483 (on an unrelated PR), green on a re-run and
green on main — a flake in the harness, not a defect in the installer.

Wait for the uninstaller process to be gone, then sweep until the
directory is, and fail loudly if thirty seconds is not enough. The
alternative — allow-listing the two files — would leave the cases
asserting on a directory they had not established was empty, which is
most of what they are for.

Floor 156 -> 157 for the one assert this adds.

Reported by the L99 session, whose PR the flake landed on.

Refs #1181


Claude-Session: https://claude.ai/code/session_0172JQqxQSWDFbQauMDuCbJ2

Signed-off-by: gen16k <gen16k@users.noreply.github.com>
Co-authored-by: gen16k <gen16k@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant