feat(cli): install the engine as the setup executor on the daemon path (waired#835 §11) - #115
Merged
Merged
Conversation
…h (waired#835 §11)
On the daemon-mediated path `waired init` returns early at main.go's
runInitViaDaemon branch and never reaches the standalone engine block, so
nothing could install an engine there. The wizard's first step could only
ever report permission_denied: the daemon cannot install unprivileged, and
no elevated process was doing it either. This is the piece §11 calls
"executor 経由のエンジン導入".
The daemon declares where to install; the executor obeys:
- internal/management: add `state_dir` to SetupStateResponse. The CLI has
no state dir on this path (runInitViaDaemon takes only a control URL,
device name and four flags), and recomputing it with defaultStateDir()
diverges silently from a daemon started with --state-dir or
$WAIRED_STATE_DIR. Divergence fails silently in the worst way: the
install succeeds, the daemon looks elsewhere, engine_install spins
forever. Published only alongside a desired engine.
- cmd/waired-agent: `setupStateDir()` on setupProvider, served from
agentInferenceProvider.stateDir — the same value bundledOllamaBin joins
against, so the two paths agree by construction. Routed through the
provider rather than a sixth newSetupReconciler parameter so main.go is
untouched.
- cmd/waired/setup_install.go: runSetupEngineInstall claims the lease
(phase=installing) BEFORE the download starts, runs the SAME
engineInstallDecision as interactive init, calls installOllama and
handStateToServiceUser, then reports done/failed. Every skip reason
reports through the lease instead of dying quietly.
- login_client.go: run it once a browser setup is actually active, before
waitForBundledModel — a pull has nothing to pull with until an engine
exists.
Notes on two judgement calls, both recorded in the PR body:
- OllamaSourceBundled is passed instead of the interactive prompt's
answer. There is no terminal question here, and we only reach this code
when the daemon reports no engine at all, so there is nothing to reuse.
- WAIRED_NO_OLLAMA opt-out reports failed with a specific error_detail
rather than growing a ninth error code. permission_denied is the closest
of the eight ("this device will not do it") and the detail carries the
real reason.
Windows is NOT special-cased: an executor started from an elevated prompt
installs fine, and an unelevated one gets the honest permission_denied
with the Administrator hint. What §20.3 defers is the elevation session
model for a browser-initiated flow, which is a separate issue.
The decision tree takes goos/elevated as parameters (setupEngineInstall),
per the repo's cross-OS rule, so all three OSes are table-tested from an
unprivileged runner — otherwise CI could never reach the install arm.
docs-site: show the line the terminal now prints during the browser
setup. The surrounding text already said this step exists.
Refs: waired-ai/waired#835
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: gen16k <gen16k@gmail.com>
|
📘 Docs preview for this PR: https://waired-docs--pr-115-4572fefz.web.app Rebuilt on each push; the preview channel auto-expires in 7 days. |
gen16k
added a commit
that referenced
this pull request
Jul 20, 2026
…ard (waired#835 §11) (#117) waired#835 #115 gave the daemon path an executor-driven engine install, but gated it on setupActive — a browser wizard actually driving. Every terminal-only daemon-path install therefore still finishes with no engine: --non-interactive, --no-browser, no TTY, pressing Enter to take the terminal back, or simply not touching the browser. This is not a future problem. macOS reaches the daemon path on its DEFAULT install today: darwin_register_agent "$state_dir" # install.sh:1150 - launchctl bootstrap darwin_maybe_init "$state_dir" # install.sh:1153 - init runs here The plist that registration installs has RunAtLoad=true (service_darwin.go: "the agent starts the moment launchctl bootstrap finishes"), so the daemon is up before init runs and daemonReachable is true. The installer also stopped pre-installing the engine. So the default macOS install has been completing with no engine while its closing banner says "installed by sign-in when local inference is on". installtest cannot see it: every enroll leg forces the standalone branch via --bypass-mode, --google-sa-login, or an explicit service stop, so the daemon path has never been exercised there at all. Changes: - ensureDaemonPathEngine: install when the HOST WANTS INFERENCE, not when a wizard is driving. The condition is read from the daemon's own inference subsystem state, so it reflects what the agent decided rather than what a flag claimed: only "no_engine" installs; "disabled" and "stopped" are deliberate operator states and anything else means an engine already exists. Bounded by engineWaitForStatus so a silent daemon cannot hang init, and it never installs on a guess. - installEngineAsExecutor: extracted from the wizard path so both entry points share one claim-decide-install-report core, with only the narration differing (saying "for the setup in your browser" on a terminal-only install is confusing). - SetupState now serves state_dir unconditionally. #115 served it only alongside a desired engine, reasoning there was nothing to install otherwise; that reasoning is what this commit falsifies. Withholding the destination is exactly what would keep a terminal-only install engine-less. - applyDaemonInitInference: re-apply --inference-enabled, --share-with-mesh and --inference-bundled-model-id after a daemon-path login. LoginStartRequest carries only a control URL and a device name, so these were accepted and silently dropped — install.ps1 has been passing --inference-enabled on Windows to no effect (§11.2). Applied through the management routes that already own these three controls; no new wire. Nil means "not passed", so absence never overwrites what the host already decided, and a model is not requested on a host that just turned inference off. Every failure warns rather than failing a login that already succeeded. Ordering is load-bearing: the flags are re-applied before awaitBrowserSetup (so nothing races desired_model_id) and before waitForBundledModel (so the terminal does not wait on a download the operator asked to skip, or download the auto-selected model and only then switch). This lands before the §11.2 installer ordering flip, which puts Linux and Windows onto the same path macOS is already on. Refs: waired-ai/waired#835 Signed-off-by: gen16k <gen16k@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gen16k
added a commit
that referenced
this pull request
Jul 27, 2026
`waired init` on a host that already has an identity was the last input that selected the standalone enrollment implementation. The daemon could not renew credentials for a device it had already enrolled -- Start returned its idempotent no-op -- so re-auth had nowhere else to go. The control plane needed nothing: EnrollDevice already matches an existing Device on the machine key and renews the row (renewDeviceTx, "#115 Phase C"), and the renewal path is exempt from the device cap. So this is agent wiring only, no proto change and no CP deploy. LoginStartRequest gains `reauth`. The daemon honours it by skipping the already-active short-circuit, and by rebuilding the live session afterwards rather than publishing a second one: the running session is using the credentials that were just replaced, and activate refuses to publish over a current session anyway. That rebuild is the same teardown-then-activate the node-key rotator has always done, so it is now one function sharing one mutex with the rotator instead of two copies that could race. chooseEnrollRoute therefore loses routeLocal and, with it, every fact that used to steer: no input picks an implementation any more, because there is only one. What remains is "is the agent there", and the table test is now the full input space rather than a sample of it. The code behind the removed route is unreachable from this commit but still present; #175's next PR deletes it together with the `waired init` flags that exist only to steer it. Splitting it that way keeps this change reviewable as a behaviour change. Two defects fixed on the way: * `--auth-key` on an already-enrolled host used to print a successful sign-in for a run that renewed nothing (routeDaemon -> the daemon's no-op). It now re-authenticates. * waitForBenchmark read `disabled` and `stopped` as "engine is up, a download must be in flight" and waited out the full ten-minute deadline before giving up -- on a host with no model and no intention of getting one. waitForBundledModel has always treated those two as terminal (init_pull.go:97); now both do. Measured cost: ten minutes per installtest leg, three legs, every PR. Found because #290's harness migration made the daemon path the one CI takes. An agent predating `reauth` ignores the field and answers with the no-op status. The CLI names that skew ("the background service is too old to renew that sign-in") instead of reporting the protocol symptom or, worse, success. Refs #175 Signed-off-by: gen16k <gen16k@gmail.com>
gen16k
added a commit
that referenced
this pull request
Jul 27, 2026
`waired init` on a host that already has an identity was the last input that selected the standalone enrollment implementation. The daemon could not renew credentials for a device it had already enrolled -- Start returned its idempotent no-op -- so re-auth had nowhere else to go. The control plane needed nothing: EnrollDevice already matches an existing Device on the machine key and renews the row (renewDeviceTx, "#115 Phase C"), and the renewal path is exempt from the device cap. So this is agent wiring only, no proto change and no CP deploy. LoginStartRequest gains `reauth`. The daemon honours it by skipping the already-active short-circuit, and by rebuilding the live session afterwards rather than publishing a second one: the running session is using the credentials that were just replaced, and activate refuses to publish over a current session anyway. That rebuild is the same teardown-then-activate the node-key rotator has always done, so it is now one function sharing one mutex with the rotator instead of two copies that could race. chooseEnrollRoute therefore loses routeLocal and, with it, every fact that used to steer: no input picks an implementation any more, because there is only one. What remains is "is the agent there", and the table test is now the full input space rather than a sample of it. The code behind the removed route is unreachable from this commit but still present; #175's next PR deletes it together with the `waired init` flags that exist only to steer it. Splitting it that way keeps this change reviewable as a behaviour change. Two defects fixed on the way: * `--auth-key` on an already-enrolled host used to print a successful sign-in for a run that renewed nothing (routeDaemon -> the daemon's no-op). It now re-authenticates. * waitForBenchmark read `disabled` and `stopped` as "engine is up, a download must be in flight" and waited out the full ten-minute deadline before giving up -- on a host with no model and no intention of getting one. waitForBundledModel has always treated those two as terminal (init_pull.go:97); now both do. Measured cost: ten minutes per installtest leg, three legs, every PR. Found because #290's harness migration made the daemon path the one CI takes. An agent predating `reauth` ignores the field and answers with the no-op status. The CLI names that skew ("the background service is too old to renew that sign-in") instead of reporting the protocol symptom or, worse, success. Refs #175 Signed-off-by: gen16k <gen16k@gmail.com>
gen16k
added a commit
that referenced
this pull request
Jul 28, 2026
`waired init` on a host that already has an identity was the last input that selected the standalone enrollment implementation. The daemon could not renew credentials for a device it had already enrolled -- Start returned its idempotent no-op -- so re-auth had nowhere else to go. The control plane needed nothing: EnrollDevice already matches an existing Device on the machine key and renews the row (renewDeviceTx, "#115 Phase C"), and the renewal path is exempt from the device cap. So this is agent wiring only, no proto change and no CP deploy. LoginStartRequest gains `reauth`. The daemon honours it by skipping the already-active short-circuit, and by rebuilding the live session afterwards rather than publishing a second one: the running session is using the credentials that were just replaced, and activate refuses to publish over a current session anyway. That rebuild is the same teardown-then-activate the node-key rotator has always done, so it is now one function sharing one mutex with the rotator instead of two copies that could race. chooseEnrollRoute therefore loses routeLocal and, with it, every fact that used to steer: no input picks an implementation any more, because there is only one. What remains is "is the agent there", and the table test is now the full input space rather than a sample of it. The code behind the removed route is unreachable from this commit but still present; #175's next PR deletes it together with the `waired init` flags that exist only to steer it. Splitting it that way keeps this change reviewable as a behaviour change. Two defects fixed on the way: * `--auth-key` on an already-enrolled host used to print a successful sign-in for a run that renewed nothing (routeDaemon -> the daemon's no-op). It now re-authenticates. * waitForBenchmark read `disabled` and `stopped` as "engine is up, a download must be in flight" and waited out the full ten-minute deadline before giving up -- on a host with no model and no intention of getting one. waitForBundledModel has always treated those two as terminal (init_pull.go:97); now both do. Measured cost: ten minutes per installtest leg, three legs, every PR. Found because #290's harness migration made the daemon path the one CI takes. An agent predating `reauth` ignores the field and answers with the no-op status. The CLI names that skew ("the background service is too old to renew that sign-in") instead of reporting the protocol symptom or, worse, success. Refs #175 Signed-off-by: gen16k <gen16k@gmail.com>
gen16k
added a commit
that referenced
this pull request
Jul 28, 2026
…#296) `waired init` on a host that already has an identity was the last input that selected the standalone enrollment implementation. The daemon could not renew credentials for a device it had already enrolled -- Start returned its idempotent no-op -- so re-auth had nowhere else to go. The control plane needed nothing: EnrollDevice already matches an existing Device on the machine key and renews the row (renewDeviceTx, "#115 Phase C"), and the renewal path is exempt from the device cap. So this is agent wiring only, no proto change and no CP deploy. LoginStartRequest gains `reauth`. The daemon honours it by skipping the already-active short-circuit, and by rebuilding the live session afterwards rather than publishing a second one: the running session is using the credentials that were just replaced, and activate refuses to publish over a current session anyway. That rebuild is the same teardown-then-activate the node-key rotator has always done, so it is now one function sharing one mutex with the rotator instead of two copies that could race. chooseEnrollRoute therefore loses routeLocal and, with it, every fact that used to steer: no input picks an implementation any more, because there is only one. What remains is "is the agent there", and the table test is now the full input space rather than a sample of it. The code behind the removed route is unreachable from this commit but still present; #175's next PR deletes it together with the `waired init` flags that exist only to steer it. Splitting it that way keeps this change reviewable as a behaviour change. Two defects fixed on the way: * `--auth-key` on an already-enrolled host used to print a successful sign-in for a run that renewed nothing (routeDaemon -> the daemon's no-op). It now re-authenticates. * waitForBenchmark read `disabled` and `stopped` as "engine is up, a download must be in flight" and waited out the full ten-minute deadline before giving up -- on a host with no model and no intention of getting one. waitForBundledModel has always treated those two as terminal (init_pull.go:97); now both do. Measured cost: ten minutes per installtest leg, three legs, every PR. Found because #290's harness migration made the daemon path the one CI takes. An agent predating `reauth` ignores the field and answers with the no-op status. The CLI names that skew ("the background service is too old to renew that sign-in") instead of reporting the protocol symptom or, worse, success. Refs #175 Signed-off-by: gen16k <gen16k@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
On the daemon-mediated path,
waired initreturns early atrunInitViaDaemon(cmd/waired/main.go:325-329) and never reaches the standalone engine block atmain.go:535. So on that path nothing could install an engine, and the wizard's first step could only ever reportpermission_denied— the daemon cannot install unprivileged, and no elevated process was doing it either.That matters more after waired#874: §11.2 flips the installers to start the daemon before
waired init, which makes the daemon path the default journey. This PR is the prerequisite that decision named — the ordering flip must not merge before this, or a fresh install gets no engine at all.The daemon declares where to install
installOllamaon Linux roots at<stateDir>/runtimes/ollama, and the daemon'sbundledOllamaBin(cmd/waired-agent/inference.go:260) joins the same path. They must agree exactly.But
runInitViaDaemon(cmd/waired/login_client.go:41) receives only a control URL, a device name and four flags — no state dir. Recomputing it CLI-side withdefaultStateDir()works on a default host and silently diverges from a daemon started with--state-diror$WAIRED_STATE_DIR. Divergence fails in the worst available way: the install succeeds, the daemon looks elsewhere,engine_installspins forever and the operator sees a stuck wizard with no error.So
GET /waired/v1/setup/stategainsstate_dir, and the executor obeys it rather than guessing. An empty value means do not install. Spec side: waired-ai/waired#876 (amends the frozen §11.1 table and reconciles it with §17.1 — that rule governs values crossing the CP trust boundary; this is a daemon's own value returned to a co-local process that could already compute it).Changes
internal/management/setup_handlers.goStateDironSetupStateResponse, served only alongside a desired enginecmd/waired-agent/setup_desired.gosetupStateDir()onsetupProvider; adapter returnsagentInferenceProvider.stateDircmd/waired/setup_install.go(new)runSetupEngineInstall— claim, decide, install, reportcmd/waired/login_client.gowaitForBundledModeldocs-site/**(+ja/)main.gois deliberately untouched. Routing the state dir throughsetupProviderinstead of a sixthnewSetupReconcilerparameter avoidscmd/waired-agent/main.go:1093— the one line that collides with open PR #113 (the Public Share usage batcher rewires daemon wiring there).Ordering inside
runSetupEngineInstallis load-bearing: the lease is claimed (phase=installing) before the multi-GB download starts, so a second executor cannot start a parallel elevated install.TestSetupEngineInstallClaimsBeforeInstallingpins it.Reuse, per §11.1 ("新しいインストーラを書かない"): the same
engineInstallDecision,installOllamaandhandStateToServiceUserinteractive init uses. The#484ownership handoff is included — the tarball is extracted as root and the unprivileged daemon cannot otherwise read what was just installed.Two judgement calls
OllamaSourceBundledinstead of the interactive prompt's answer. There is no terminal question on this path. We only reach the decision when the daemon reports no engine installed at all, so there is nothing to reuse — a host that already has one returns atEngineInstalled.WAIRED_NO_OLLAMAopt-out reportsfailedwith a specificerror_detail, not a ninth error code. The operator just asked for an engine in the browser while the host is configured never to install one; that is a genuine conflict and must be visible. Of the eight frozen codes,permission_denied("this device will not do it") is closest, and the detail carries the real reason. Recorded in waired#876's decision entry.Windows
Not special-cased, on purpose.
installOllamaalready exists on Windows with the same signature and runs fine from an elevated prompt; an unelevated executor gets the honestpermission_deniedpluselevation.HintFor("windows", …), which is exactly the recovery copy the wizard should show. Gating the code off on Windows would be strictly worse.What §20.3 defers is narrower — whether a browser-initiated flow can obtain an elevated executor on Windows without a
sudoequivalent — and that is subordinate to waired#759's two-stage elevation session model. Filing it as an OS-titled deferral issue separately, since it is a session-model question rather than anything in this diff.Tests
New (
cmd/waired/setup_install_test.go): happy path (install called with the daemon's state dir + ownership handoff +done), claim-before-install ordering, failure detail propagation, 5 skip conditions (inactive / no desire / already installed / claimed by another / vllm), refusal without a state dir, and a 7-case cross-OS table covering elevated and unelevated Linux/Windows, macOS unelevated-installs and macOS-already-present, plus opt-out.The decision tree takes
goos/elevatedas parameters (setupEngineInstall) per the repo's cross-OS rule. Without that split CI could never exercise the install arm at all — runners are unprivileged, soelevation.IsElevated()is always false and every case would collapse toSkipNotElevated.Also:
state_dirprojection + omission on the agent side,state_diron the wire in the handler test, andfakeSetupDaemonnow mirrors the daemon's lease-bound install latch so executor tests see the claim they would see in production.Verification
Not verified locally: the real install itself (a ~GB download behind an elevation gate). The seam is the same
installOllamathe interactive path has shipped with, and the 3-OS installtest legs cover it end to end.Refs
🤖 Generated with Claude Code