Skip to content

fix(cli): stop waired init enrolling locally when the background service isn't answering (#175) - #271

Merged
gen16k merged 1 commit into
mainfrom
fix/175-no-implicit-local-enroll
Jul 27, 2026
Merged

gen16k merged 1 commit into
mainfrom
fix/175-no-implicit-local-enroll

Conversation

@gen16k

@gen16k gen16k commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Why

waired init picked between two entirely separate enrollment implementations from a single 1-second probe (cmd/waired/login_client.go daemonReachable), with no user-visible signal and no failure mode — any transport error selected the local enrollment path.

The two are not equivalent. Agent capabilities (onboarding-v1/v2, public-share-v1) are declared only on the daemon's network-map poll (internal/controlclient/network_map.go), whose only non-test caller is the daemon. So a host whose service failed to start enrolled "successfully" and then dead-ended the browser wizard permanently, with nothing on the machine saying why.

The accurate framing of the bug: the fallback hides the fact that the background service isn't running. It converted a loud, local, fixable failure into a silent, remote, unfixable one.

The 1-second window also lost a plain start-up race — all three installers now register and start the service immediately before invoking init (install.sh linux_service_up → linux_maybe_init, darwin_register_agent → darwin_maybe_init, install.ps1 Ensure-AgentRunning → Invoke-WairedInit), so on the production journey the local path was already reachable only when something was broken.

What changed

The probe no longer decides which implementation runs; it decides whether an agent is there at all.

  • chooseEnrollRoute (new cmd/waired/init_route_daemon.go) is a pure (facts) -> route function over four states — routeDaemon / routeLocal / routeAgentDown / routeAgentAbsent — table-tested over every combination. Local enrollment runs only when explicitly selected (--bypass-mode, --google-sa-login, re-auth), and those skip the probe entirely.
  • A registered service that never answers gets a 20s wait window (reusing waitDaemonReachable, moved here from init_benchmark.go), then fails with waired doctor plus the platform's start command instead of downgrading. No registered service and nothing answering fails too. Both messages come from a goos-parameterised function, so all three platforms are asserted from one host.
  • No new opt-in flag. Tailscale has no local-enroll flag because it has no local enrollment: tailscale up fails with an OS-specific "tailscaled doesn't appear to be running" (cmd/tailscale/cli/diag.go), and even the official container image starts tailscaled first (cmd/containerboot/main.go). Matching that is the point of the issue.

Windows presence-check fix (same PR per §Cross-OS parity)

service.Installed() used mgr.Connect(), which opens the SCM with SC_MANAGER_ALL_ACCESS and therefore fails without Administrator — a non-elevated waired init saw a registered service as absent, so routeAgentDown could never fire on Windows. It now asks for SC_MANAGER_CONNECT + SERVICE_QUERY_STATUS.

The test pins the rights, not the outcome: CI's Windows runner is already elevated, so an outcome-based test would pass with the old code and prove nothing.

installtest

installtest-enroll.sh stopped the daemon before init for every mode. The interactive mode carries no explicit selector, so it now keeps the daemon up and takes the daemon journey — which is what a real interactive install does anyway. bypass / oidc keep stopping it: they select the local path by flag.

Verification

  • go vet ./..., golangci-lint run (0 issues), go test ./... -timeout 10m — green
  • go build -tags prod ./... + go vet -tags prod ./... + go test -tags prod ./internal/buildflag/... — green
  • make verify-cross — green (the Windows change compiles and vets under GOOS=windows)
  • docs-site: node scripts/i18n-sync.mjs --check → 32 pairs in sync
  • New tests: route table (7 cases incl. "explicit selector must not probe"), start-up-race wait window, no-wait-without-a-service, 6-case × 3-GOOS message table, Windows access-rights + SCM read path

Not verified here: the end-to-end Linux/macOS/Windows behaviour on a real host with the service stopped — installtest.yml covers the enrol legs, and the failure paths are unit-tested at the seam.

Scope

First of four PRs on #175. Deliberately not in this one:

  • --auth-key and moving the headless credentials (--bypass-mode / --google-sa-login) daemon-side — needs a control-plane change first, since neither works against a production CP today (both are compiled out with -tags prod on the CP side)
  • daemon-side re-auth (--force-reauth)
  • deleting the local enrollment path and init_engine.go, and converging engine install on setup_install.go
  • flipping the container bootstrap ordering

Those are planned in the split-plan comment on #175 and will be re-planned once this lands.

Refs #175

… answering

`waired init` picked between two entirely separate enrollment
implementations from a single 1-second probe, with no user-visible signal
and no failure mode: any transport error selected the local enrollment
path. That path declares no agent capabilities — onboarding-v1/v2 and
public-share-v1 are sent only on the daemon's network-map poll — so a host
whose service failed to start enrolled "successfully" and then dead-ended
the browser wizard permanently. The fallback converted a loud, local,
fixable failure (the service didn't start) into a silent, remote,
unfixable one. The 1-second window also lost a plain start-up race: all
three installers now register and start the service immediately before
invoking init.

The probe no longer decides *which implementation runs*; it decides
whether an agent is there at all:

  - chooseEnrollRoute (init_route_daemon.go) is a pure (facts) -> route
    function over the four states, table-tested over every combination.
    Local enrollment runs only when explicitly selected — --bypass-mode,
    --google-sa-login, re-auth — and those skip the probe entirely.
  - A registered service that never answers gets a 20s wait window, then
    fails with `waired doctor` plus the platform's start command, instead
    of downgrading. No agent at all fails too. Both messages are built by
    a goos-parameterised function so all three platforms are testable
    from one host.
  - No new opt-in flag: Tailscale has no local-enroll flag because it has
    no local enrollment, and `tailscale up` fails the same way when
    tailscaled isn't answering.

Windows could never have reported "installed but not responding": the
presence check used mgr.Connect(), which opens the SCM with
SC_MANAGER_ALL_ACCESS and so fails without Administrator, reporting a
registered service as absent. It now asks for SC_MANAGER_CONNECT +
SERVICE_QUERY_STATUS, and the rights are pinned by a test (CI's Windows
runner is elevated, so an outcome-based test would prove nothing).

installtest's interactive enrol leg stopped the daemon before init to
reach the local path; with no explicit selector it now keeps the daemon
up and takes the daemon journey, which is what a real interactive install
does. bypass/oidc keep stopping it — they select locally by flag.

First of four PRs on #175; the remaining local-enroll consumers (headless
credentials, re-auth) move daemon-side before the path itself is deleted.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>
@github-actions

Copy link
Copy Markdown

📘 Docs preview for this PR: https://waired-docs--pr-271-7jxl8beb.web.app

Rebuilt on each push; the preview channel auto-expires in 7 days.

@gen16k
gen16k merged commit e20f889 into main Jul 27, 2026
20 checks passed
gen16k added a commit that referenced this pull request Jul 27, 2026
Nothing reaches it any more. PR-1 (#271) removed the implicit fallback,
#290/#291 removed --google-sa-login and --bypass-mode, and the daemon
re-auth that landed just before this removed the last selector. What is
left is ~3,300 lines that cannot run.

Deleted:

* cmd/waired/main.go's runInitBody tail -- everything after the route
  switch (~410 lines), plus chooseListenAddr, printInitSuccessBox,
  claudeRouteEligible and startAgentServiceBestEffort.
* internal/setup/init.go (Init/InitOptions/InitResult) and deploy.go's
  Deploy/Puller/PullEvent. What survives deploy.go is the Ollama
  detection both sides still use, so the file is renamed
  ollama_detect.go rather than left named after what it no longer does.
* The CLI-local halves of the journey: offerBenchmark, ensureBundledEngine,
  promptInference, promptOllamaSource, applyBundledModelSelection,
  initStepLabels, cliPullProgressSink, flagBoolPtr, and the GNOME
  AppIndicator installer.
* Seven `waired init` flags that existed only to steer it: --listen,
  --endpoint, --skip-deploy, --start-agent, --no-wait-model,
  --ollama-source, --reset-config. Removed outright, so a script still
  passing one fails at the flag rather than being quietly ignored. Both
  docs-site languages updated; no installer passes any of them (checked).

Kept, because they are shared and easy to delete by mistake:
setup.Enroll (the daemon's enrollFunc), setup.Integration (`waired link`,
`waired doctor`), confirmRenew, handStateToServiceUser (also called by
`runtimes install`), benchmarkWithScanner and waitForBundledModel.

--skip-claude-route is NOT removed even though nothing reads it now:
install.ps1:1841 passes it, so deleting the flag would fail every
Windows install that used -SkipClaudeProxy.

Two gaps this makes permanent, neither caused by it -- both have been
unreachable for as long as routeDaemon has been the path every install
takes, and both are filed rather than buried:

* #294 -- the daemon-driven init never writes Claude Code managed
  settings, and the installers dropped their own `waired claude enable`
  step on the understanding that init does it. So a CLI install finishes
  unrouted, and --skip-claude-route opts out of something that was not
  going to happen. The routing sentinel does not see this: it exercises
  the gateway directly, not Claude Code's settings.
* #295 -- nothing installs the GNOME AppIndicator host extension any
  more; the waired-tray package's Suggests is what is left.

internal/setup's package doc described three phases run in-process by
`waired init`; it now describes what the package actually is, which is
the enrollment/integration/detection pieces the daemon and the CLI share.

Fixes #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 27, 2026
Nothing reaches it any more. PR-1 (#271) removed the implicit fallback,
#290/#291 removed --google-sa-login and --bypass-mode, and the daemon
re-auth that landed just before this removed the last selector. What is
left is ~3,300 lines that cannot run.

Deleted:

* cmd/waired/main.go's runInitBody tail -- everything after the route
  switch (~410 lines), plus chooseListenAddr, printInitSuccessBox,
  claudeRouteEligible and startAgentServiceBestEffort.
* internal/setup/init.go (Init/InitOptions/InitResult) and deploy.go's
  Deploy/Puller/PullEvent. What survives deploy.go is the Ollama
  detection both sides still use, so the file is renamed
  ollama_detect.go rather than left named after what it no longer does.
* The CLI-local halves of the journey: offerBenchmark, ensureBundledEngine,
  promptInference, promptOllamaSource, applyBundledModelSelection,
  initStepLabels, cliPullProgressSink, flagBoolPtr, and the GNOME
  AppIndicator installer.
* Seven `waired init` flags that existed only to steer it: --listen,
  --endpoint, --skip-deploy, --start-agent, --no-wait-model,
  --ollama-source, --reset-config. Removed outright, so a script still
  passing one fails at the flag rather than being quietly ignored. Both
  docs-site languages updated; no installer passes any of them (checked).

Kept, because they are shared and easy to delete by mistake:
setup.Enroll (the daemon's enrollFunc), setup.Integration (`waired link`,
`waired doctor`), confirmRenew, handStateToServiceUser (also called by
`runtimes install`), benchmarkWithScanner and waitForBundledModel.

--skip-claude-route is NOT removed even though nothing reads it now:
install.ps1:1841 passes it, so deleting the flag would fail every
Windows install that used -SkipClaudeProxy.

Two gaps this makes permanent, neither caused by it -- both have been
unreachable for as long as routeDaemon has been the path every install
takes, and both are filed rather than buried:

* #294 -- the daemon-driven init never writes Claude Code managed
  settings, and the installers dropped their own `waired claude enable`
  step on the understanding that init does it. So a CLI install finishes
  unrouted, and --skip-claude-route opts out of something that was not
  going to happen. The routing sentinel does not see this: it exercises
  the gateway directly, not Claude Code's settings.
* #295 -- nothing installs the GNOME AppIndicator host extension any
  more; the waired-tray package's Suggests is what is left.

internal/setup's package doc described three phases run in-process by
`waired init`; it now describes what the package actually is, which is
the enrollment/integration/detection pieces the daemon and the CLI share.

Fixes #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 28, 2026
…#297)

* refactor(cli): delete the standalone enrollment implementation (#175)

Nothing reaches it any more. PR-1 (#271) removed the implicit fallback,
#290/#291 removed --google-sa-login and --bypass-mode, and the daemon
re-auth that landed just before this removed the last selector. What is
left is ~3,300 lines that cannot run.

Deleted:

* cmd/waired/main.go's runInitBody tail -- everything after the route
  switch (~410 lines), plus chooseListenAddr, printInitSuccessBox,
  claudeRouteEligible and startAgentServiceBestEffort.
* internal/setup/init.go (Init/InitOptions/InitResult) and deploy.go's
  Deploy/Puller/PullEvent. What survives deploy.go is the Ollama
  detection both sides still use, so the file is renamed
  ollama_detect.go rather than left named after what it no longer does.
* The CLI-local halves of the journey: offerBenchmark, ensureBundledEngine,
  promptInference, promptOllamaSource, applyBundledModelSelection,
  initStepLabels, cliPullProgressSink, flagBoolPtr, and the GNOME
  AppIndicator installer.
* Seven `waired init` flags that existed only to steer it: --listen,
  --endpoint, --skip-deploy, --start-agent, --no-wait-model,
  --ollama-source, --reset-config. Removed outright, so a script still
  passing one fails at the flag rather than being quietly ignored. Both
  docs-site languages updated; no installer passes any of them (checked).

Kept, because they are shared and easy to delete by mistake:
setup.Enroll (the daemon's enrollFunc), setup.Integration (`waired link`,
`waired doctor`), confirmRenew, handStateToServiceUser (also called by
`runtimes install`), benchmarkWithScanner and waitForBundledModel.

--skip-claude-route is NOT removed even though nothing reads it now:
install.ps1:1841 passes it, so deleting the flag would fail every
Windows install that used -SkipClaudeProxy.

Two gaps this makes permanent, neither caused by it -- both have been
unreachable for as long as routeDaemon has been the path every install
takes, and both are filed rather than buried:

* #294 -- the daemon-driven init never writes Claude Code managed
  settings, and the installers dropped their own `waired claude enable`
  step on the understanding that init does it. So a CLI install finishes
  unrouted, and --skip-claude-route opts out of something that was not
  going to happen. The routing sentinel does not see this: it exercises
  the gateway directly, not Claude Code's settings.
* #295 -- nothing installs the GNOME AppIndicator host extension any
  more; the waired-tray package's Suggests is what is left.

internal/setup's package doc described three phases run in-process by
`waired init`; it now describes what the package actually is, which is
the enrollment/integration/detection pieces the daemon and the CLI share.

Fixes #175

Signed-off-by: gen16k <gen16k@gmail.com>

* refactor(cli): drop two comments that outlived their code

helpers.go is deleted, and runInitBody is no longer the 460-line body its
doc comment described.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>

---------

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 28, 2026
Nothing reaches it any more. PR-1 (#271) removed the implicit fallback,
re-auth that landed just before this removed the last selector. What is
left is ~3,300 lines that cannot run.

Deleted:

* cmd/waired/main.go's runInitBody tail -- everything after the route
  switch (~410 lines), plus chooseListenAddr, printInitSuccessBox,
  claudeRouteEligible and startAgentServiceBestEffort.
* internal/setup/init.go (Init/InitOptions/InitResult) and deploy.go's
  Deploy/Puller/PullEvent. What survives deploy.go is the Ollama
  detection both sides still use, so the file is renamed
  ollama_detect.go rather than left named after what it no longer does.
* The CLI-local halves of the journey: offerBenchmark, ensureBundledEngine,
  promptInference, promptOllamaSource, applyBundledModelSelection,
  initStepLabels, cliPullProgressSink, flagBoolPtr, and the GNOME
  AppIndicator installer.
* Seven `waired init` flags that existed only to steer it: --listen,
  --endpoint, --skip-deploy, --start-agent, --no-wait-model,
  --ollama-source, --reset-config. Removed outright, so a script still
  passing one fails at the flag rather than being quietly ignored. Both
  docs-site languages updated; no installer passes any of them (checked).

Kept, because they are shared and easy to delete by mistake:
setup.Enroll (the daemon's enrollFunc), setup.Integration (`waired link`,
`waired doctor`), confirmRenew, handStateToServiceUser (also called by
`runtimes install`), benchmarkWithScanner and waitForBundledModel.

--skip-claude-route is NOT removed even though nothing reads it now:
install.ps1:1841 passes it, so deleting the flag would fail every
Windows install that used -SkipClaudeProxy.

Two gaps this makes permanent, neither caused by it -- both have been
unreachable for as long as routeDaemon has been the path every install
takes, and both are filed rather than buried:

* #294 -- the daemon-driven init never writes Claude Code managed
  settings, and the installers dropped their own `waired claude enable`
  step on the understanding that init does it. So a CLI install finishes
  unrouted, and --skip-claude-route opts out of something that was not
  going to happen. The routing sentinel does not see this: it exercises
  the gateway directly, not Claude Code's settings.
* #295 -- nothing installs the GNOME AppIndicator host extension any
  more; the waired-tray package's Suggests is what is left.

internal/setup's package doc described three phases run in-process by
`waired init`; it now describes what the package actually is, which is
the enrollment/integration/detection pieces the daemon and the CLI share.

Fixes #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 28, 2026
Nothing reaches it any more. PR-1 (#271) removed the implicit fallback,
re-auth that landed just before this removed the last selector. What is
left is ~3,300 lines that cannot run.

Deleted:

* cmd/waired/main.go's runInitBody tail -- everything after the route
  switch (~410 lines), plus chooseListenAddr, printInitSuccessBox,
  claudeRouteEligible and startAgentServiceBestEffort.
* internal/setup/init.go (Init/InitOptions/InitResult) and deploy.go's
  Deploy/Puller/PullEvent. What survives deploy.go is the Ollama
  detection both sides still use, so the file is renamed
  ollama_detect.go rather than left named after what it no longer does.
* The CLI-local halves of the journey: offerBenchmark, ensureBundledEngine,
  promptInference, promptOllamaSource, applyBundledModelSelection,
  initStepLabels, cliPullProgressSink, flagBoolPtr, and the GNOME
  AppIndicator installer.
* Seven `waired init` flags that existed only to steer it: --listen,
  --endpoint, --skip-deploy, --start-agent, --no-wait-model,
  --ollama-source, --reset-config. Removed outright, so a script still
  passing one fails at the flag rather than being quietly ignored. Both
  docs-site languages updated; no installer passes any of them (checked).

Kept, because they are shared and easy to delete by mistake:
setup.Enroll (the daemon's enrollFunc), setup.Integration (`waired link`,
`waired doctor`), confirmRenew, handStateToServiceUser (also called by
`runtimes install`), benchmarkWithScanner and waitForBundledModel.

--skip-claude-route is NOT removed even though nothing reads it now:
install.ps1:1841 passes it, so deleting the flag would fail every
Windows install that used -SkipClaudeProxy.

Two gaps this makes permanent, neither caused by it -- both have been
unreachable for as long as routeDaemon has been the path every install
takes, and both are filed rather than buried:

* #294 -- the daemon-driven init never writes Claude Code managed
  settings, and the installers dropped their own `waired claude enable`
  step on the understanding that init does it. So a CLI install finishes
  unrouted, and --skip-claude-route opts out of something that was not
  going to happen. The routing sentinel does not see this: it exercises
  the gateway directly, not Claude Code's settings.
* #295 -- nothing installs the GNOME AppIndicator host extension any
  more; the waired-tray package's Suggests is what is left.

internal/setup's package doc described three phases run in-process by
`waired init`; it now describes what the package actually is, which is
the enrollment/integration/detection pieces the daemon and the CLI share.

Fixes #175

Signed-off-by: gen16k <gen16k@gmail.com>
gen16k added a commit that referenced this pull request Jul 28, 2026
…#301)

* refactor(cli): delete the standalone enrollment implementation (#175)

Nothing reaches it any more. PR-1 (#271) removed the implicit fallback,
re-auth that landed just before this removed the last selector. What is
left is ~3,300 lines that cannot run.

Deleted:

* cmd/waired/main.go's runInitBody tail -- everything after the route
  switch (~410 lines), plus chooseListenAddr, printInitSuccessBox,
  claudeRouteEligible and startAgentServiceBestEffort.
* internal/setup/init.go (Init/InitOptions/InitResult) and deploy.go's
  Deploy/Puller/PullEvent. What survives deploy.go is the Ollama
  detection both sides still use, so the file is renamed
  ollama_detect.go rather than left named after what it no longer does.
* The CLI-local halves of the journey: offerBenchmark, ensureBundledEngine,
  promptInference, promptOllamaSource, applyBundledModelSelection,
  initStepLabels, cliPullProgressSink, flagBoolPtr, and the GNOME
  AppIndicator installer.
* Seven `waired init` flags that existed only to steer it: --listen,
  --endpoint, --skip-deploy, --start-agent, --no-wait-model,
  --ollama-source, --reset-config. Removed outright, so a script still
  passing one fails at the flag rather than being quietly ignored. Both
  docs-site languages updated; no installer passes any of them (checked).

Kept, because they are shared and easy to delete by mistake:
setup.Enroll (the daemon's enrollFunc), setup.Integration (`waired link`,
`waired doctor`), confirmRenew, handStateToServiceUser (also called by
`runtimes install`), benchmarkWithScanner and waitForBundledModel.

--skip-claude-route is NOT removed even though nothing reads it now:
install.ps1:1841 passes it, so deleting the flag would fail every
Windows install that used -SkipClaudeProxy.

Two gaps this makes permanent, neither caused by it -- both have been
unreachable for as long as routeDaemon has been the path every install
takes, and both are filed rather than buried:

* #294 -- the daemon-driven init never writes Claude Code managed
  settings, and the installers dropped their own `waired claude enable`
  step on the understanding that init does it. So a CLI install finishes
  unrouted, and --skip-claude-route opts out of something that was not
  going to happen. The routing sentinel does not see this: it exercises
  the gateway directly, not Claude Code's settings.
* #295 -- nothing installs the GNOME AppIndicator host extension any
  more; the waired-tray package's Suggests is what is left.

internal/setup's package doc described three phases run in-process by
`waired init`; it now describes what the package actually is, which is
the enrollment/integration/detection pieces the daemon and the CLI share.

Fixes #175

Signed-off-by: gen16k <gen16k@gmail.com>

* refactor(cli): drop two comments that outlived their code

helpers.go is deleted, and runInitBody is no longer the 460-line body its
doc comment described.

Refs #175

Signed-off-by: gen16k <gen16k@gmail.com>

---------

Signed-off-by: gen16k <gen16k@gmail.com>
@gen16k
gen16k deleted the fix/175-no-implicit-local-enroll branch August 3, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant