From bb87ecd4a742e7fc7b0e34744733a10ddeab92ba Mon Sep 17 00:00:00 2001 From: gen16k Date: Mon, 20 Jul 2026 10:44:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20install=20the=20engine=20as=20the?= =?UTF-8?q?=20setup=20executor=20on=20the=20daemon=20path=20(waired#835=20?= =?UTF-8?q?=C2=A711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/waired-ai/waired/issues/835 Co-authored-by: Claude Opus 4.8 (1M context) Signed-off-by: gen16k --- cmd/waired-agent/setup_desired.go | 12 + cmd/waired-agent/setup_desired_test.go | 30 ++ cmd/waired/login_client.go | 11 + cmd/waired/setup_executor_test.go | 19 + cmd/waired/setup_install.go | 145 ++++++++ cmd/waired/setup_install_test.go | 333 ++++++++++++++++++ .../docs/getting-started/first-run.mdx | 9 +- .../docs/ja/getting-started/first-run.mdx | 9 +- internal/management/setup_handlers.go | 15 + internal/management/setup_handlers_test.go | 6 + 10 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 cmd/waired/setup_install.go create mode 100644 cmd/waired/setup_install_test.go diff --git a/cmd/waired-agent/setup_desired.go b/cmd/waired-agent/setup_desired.go index dd8d19f81..879b582ef 100644 --- a/cmd/waired-agent/setup_desired.go +++ b/cmd/waired-agent/setup_desired.go @@ -62,6 +62,9 @@ type setupDesired struct { type setupProvider interface { // setupEngineState reports (installed, ready) for one engine kind. setupEngineState(ctx context.Context, engine string) (installed, ready bool) + // setupStateDir is the agent's state root, published to the executor + // so a bundled engine lands where this daemon will look for it. + setupStateDir() string // setupModelState reports one catalog model's lifecycle state plus // live pull bytes and any stored failure detail. setupModelState(modelID string) (state string, completed, total int64, errText string) @@ -314,6 +317,10 @@ func (r *setupReconciler) SetupState(ctx context.Context) management.SetupStateR if d.engine != "" { resp.EngineInstalled, resp.EngineReady = r.provider.setupEngineState(ctx, d.engine) + // Only published alongside a desired engine: it exists to tell + // an executor where to install, and there is nothing to install + // otherwise. + resp.StateDir = r.provider.setupStateDir() } return resp } @@ -541,6 +548,11 @@ func (p *agentInferenceProvider) setupEngineState(ctx context.Context, engine st return true, r } +// setupStateDir is the agent's state root. The executor installs the +// bundled engine relative to this, so it matches bundledOllamaBin's +// join (inference.go) by construction rather than by coincidence. +func (p *agentInferenceProvider) setupStateDir() string { return p.stateDir } + // setupModelState reports one catalog model's lifecycle state, live // pull bytes (while downloading) and the stored failure detail. func (p *agentInferenceProvider) setupModelState(modelID string) (string, int64, int64, string) { diff --git a/cmd/waired-agent/setup_desired_test.go b/cmd/waired-agent/setup_desired_test.go index 1ae23b1a8..9df319d02 100644 --- a/cmd/waired-agent/setup_desired_test.go +++ b/cmd/waired-agent/setup_desired_test.go @@ -34,6 +34,7 @@ type fakeSetupProvider struct { benchStarts []int pulls []string pullErr error + stateDir string } func (f *fakeSetupProvider) setupEngineState(context.Context, string) (bool, bool) { @@ -42,6 +43,12 @@ func (f *fakeSetupProvider) setupEngineState(context.Context, string) (bool, boo return f.engineInstalled, f.engineReady } +func (f *fakeSetupProvider) setupStateDir() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.stateDir +} + func (f *fakeSetupProvider) setupModelState(string) (string, int64, int64, string) { f.mu.Lock() defer f.mu.Unlock() @@ -626,6 +633,29 @@ func TestSetupStateProjection(t *testing.T) { } } +// TestSetupStatePublishesStateDir: the executor has no state dir of its +// own on the daemon path (runInitViaDaemon never receives one), and a +// CLI-side guess diverges silently from a daemon started with +// --state-dir. So the daemon declares it (waired#835 §11.1). +func TestSetupStatePublishesStateDir(t *testing.T) { + f := &fakeSetupProvider{stateDir: "/var/lib/waired"} + r, _ := leasedReconciler(t, f, "ollama", "") + if st := r.SetupState(context.Background()); st.StateDir != "/var/lib/waired" { + t.Fatalf("state = %+v, want the provider's state dir published", st) + } +} + +// TestSetupStateOmitsStateDirWithoutDesiredEngine: the field exists to +// say where to install, so it has no business being served when nothing +// is to be installed. +func TestSetupStateOmitsStateDirWithoutDesiredEngine(t *testing.T) { + f := &fakeSetupProvider{stateDir: "/var/lib/waired"} + r, _ := leasedReconciler(t, f, "", "m-1") + if st := r.SetupState(context.Background()); st.StateDir != "" { + t.Fatalf("state = %+v, want no state dir without a desired engine", st) + } +} + // TestSetupStateBeforeAnyDesiredFrame: an executor that polls before the // operator has clicked anything must see active=false rather than an // error, so it can keep waiting out its grace. diff --git a/cmd/waired/login_client.go b/cmd/waired/login_client.go index 04525e644..5c177df62 100644 --- a/cmd/waired/login_client.go +++ b/cmd/waired/login_client.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "encoding/json" "errors" "fmt" @@ -117,6 +118,16 @@ func runInitViaDaemon(mgmtURL, control, deviceName string, noBrowser, nonInterac fmt.Fprintf(os.Stderr, "warn: coding-agent integration had problems (%v); re-run later: waired link --force all\n", err) } + // §11: on this path init returned long before reaching the + // standalone engine block, so nothing here could ever install + // an engine and the wizard's first step could only report + // permission_denied. As the elevated executor holding the + // lease, do the install the browser just asked for. Blocking + // is correct: the model pull below has nothing to pull with + // until an engine exists. + if setupActive { + runSetupEngineInstall(context.Background(), sess, os.Stdout) + } // #756: the daemon pulls the bundled model in the background // after enroll, so the daemon-mediated init used to return while a // multi-GB download ran invisibly. Block in the foreground with the diff --git a/cmd/waired/setup_executor_test.go b/cmd/waired/setup_executor_test.go index bccf490c6..290abba88 100644 --- a/cmd/waired/setup_executor_test.go +++ b/cmd/waired/setup_executor_test.go @@ -47,6 +47,17 @@ func (d *fakeSetupDaemon) server(t *testing.T) *httptest.Server { d.requests = append(d.requests, req) d.state.ExecutorAttached = req.Attached d.state.ExecutorElevated = req.Attached && req.Elevated + // Mirror the daemon's lease-bound install latch (§11.1) so the + // executor tests see the claim they would see in production. + switch { + case !req.Attached: + d.state.InstallClaimed = "" + case req.Phase == management.SetupExecutorPhaseInstalling && req.Engine != "": + d.state.InstallClaimed = req.Engine + case req.Phase == management.SetupExecutorPhaseDone || + req.Phase == management.SetupExecutorPhaseFailed: + d.state.InstallClaimed = "" + } _ = json.NewEncoder(w).Encode(d.state) }) srv := httptest.NewServer(mux) @@ -60,6 +71,14 @@ func (d *fakeSetupDaemon) setActive(active bool) { d.state.Active = active } +// setState replaces the served state wholesale, for tests that need to +// script more than the active flag. +func (d *fakeSetupDaemon) setState(st management.SetupStateResponse) { + d.mu.Lock() + defer d.mu.Unlock() + d.state = st +} + func (d *fakeSetupDaemon) noted() []management.SetupExecutorRequest { d.mu.Lock() defer d.mu.Unlock() diff --git a/cmd/waired/setup_install.go b/cmd/waired/setup_install.go new file mode 100644 index 000000000..7c1004b0b --- /dev/null +++ b/cmd/waired/setup_install.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "io" + "os" + "runtime" + + "github.com/waired-ai/waired-agent/internal/agentconfig" + "github.com/waired-ai/waired-agent/internal/management" + "github.com/waired-ai/waired-agent/internal/platform/elevation" + "github.com/waired-ai/waired-agent/internal/setup" +) + +// setupInstallEngine is the install seam so the executor path is +// table-testable without downloading a ~GB engine. It is the same +// per-OS installOllama the interactive path uses (waired#835 §11.1 +// requires reuse, not a second installer). +var setupInstallEngine = installOllama + +// setupDetectEngine is the detection seam, for the same reason. +var setupDetectEngine = setup.DetectOllama + +// setupHandState is the ownership-handoff seam. The real one shells out +// to chown and self-guards on euid 0 + an installed service, which a +// test running as root on a developer box would actually satisfy. +var setupHandState = handStateToServiceUser + +// runSetupEngineInstall performs the engine install the browser wizard +// asked for, as the elevated executor holding the lease. +// +// This is the daemon-path counterpart of ensureBundledEngine +// (init_engine.go): on the daemon path `waired init` returns early at +// main.go's runInitViaDaemon branch and never reaches the standalone +// engine block, so without this the wizard's first step could only ever +// report permission_denied. The decision itself goes through the SAME +// engineInstallDecision as interactive init, so opt-out, already-present, +// reuse and not-elevated all resolve identically (§11.1). +// +// It never returns an error: the outcome is reported to the daemon, +// which is what NAVI renders. Like ensureBundledEngine, a failure here +// must not fail login. +func runSetupEngineInstall(ctx context.Context, s *executorSession, out io.Writer) { + setupEngineInstall(ctx, s, out, runtime.GOOS, elevation.IsElevated()) +} + +// setupEngineInstall is runSetupEngineInstall with the two host facts +// that vary by OS passed in, so the whole decision tree is table-testable +// on every OS from an unprivileged CI runner (repo rule: route +// GOOS-varying decisions through a function taking runtime.GOOS). +func setupEngineInstall(ctx context.Context, s *executorSession, out io.Writer, goos string, elevated bool) { + if !s.Supported() { + return + } + st := s.State() + if !st.Active || st.DesiredEngine == "" || st.EngineInstalled { + return + } + // vllm has its own installer with a different shape (a venv, not a + // tarball) and no wizard offers it yet; leave it to the daemon's + // existing reporting rather than half-supporting it here. + if st.DesiredEngine != "ollama" { + return + } + // A live lease already claimed this install. The claim is bound to + // the lease (§11.1), so a stale one cannot be here — whoever holds + // it is alive and working. + if st.InstallClaimed != "" { + return + } + // The daemon could not tell us where to install. Guessing would risk + // installing somewhere this daemon never looks, which presents to the + // operator as an install that "worked" and a step that never turns + // green. + if st.StateDir == "" { + s.Failed(st.DesiredEngine, "the background service did not report where to install the engine") + return + } + + claimed := s.Installing(st.DesiredEngine) + if claimed.InstallClaimed != "" && claimed.InstallClaimed != st.DesiredEngine { + // Another executor got there first with a different engine. + return + } + + bundledPresent := false + if p := bundledEnginePath(goos, st.StateDir); p != "" { + if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() { + bundledPresent = true + } + } + // OllamaSourceBundled, not the interactive prompt's answer: there is + // no terminal question on this path, and we only get here when the + // daemon reports no engine installed at all — so there is nothing to + // reuse. A host that already has one never reaches this line. + action := engineInstallDecision( + goos, elevated, setupDetectEngine(ctx), + agentconfig.OllamaSourceBundled, bundledPresent, + os.Getenv("WAIRED_NO_OLLAMA") != "") + + switch action { + case engineActionInstall: + writePromptf(out, "%s Installing the AI engine for the setup in your browser (one-time download)...\n", emo("📦", ">>")) + if err := setupInstallEngine(true, st.StateDir); err != nil { + writePromptf(out, "%s Engine install failed: %v\n", emo("⚠️", "!"), err) + s.Failed(st.DesiredEngine, err.Error()) + return + } + // The tarball was extracted as root; hand the state dir back or + // the unprivileged daemon cannot read what we just installed + // (Linux only, no-op elsewhere). + setupHandState(st.StateDir) + writePromptf(out, "%s AI engine installed.\n", emo("✅", "*")) + s.Done(st.DesiredEngine) + + case engineActionSkipPresent, engineActionSkipReuse: + // Nothing to install. Report done so the wizard advances instead + // of waiting on the daemon's next profile refresh. + s.Done(st.DesiredEngine) + + case engineActionSkipNotElevated: + // The daemon already reports permission_denied for an unelevated + // lease; say it in the executor's own words so error_detail names + // the command that fixes it. + s.Failed(st.DesiredEngine, + "the setup command on this device is not running with administrator privileges; "+ + elevation.Hint("waired init")) + + case engineActionSkipOptOut: + // Engine installs are turned off on this host, but someone just + // asked for one in the browser. permission_denied is the closest + // of the eight codes ("this device will not do it"); the detail + // carries the real reason (waired#835 decisions 20260720 13:00). + writePrompt(out, "Engine install skipped (WAIRED_NO_OLLAMA).") + s.Failed(st.DesiredEngine, + "engine installs are turned off on this device (WAIRED_NO_OLLAMA)") + } +} + +// setupEngineInstallWanted reports whether the daemon's state calls for +// an executor-driven engine install. Split out so the caller can decide +// without a second round trip's worth of duplicated conditions. +func setupEngineInstallWanted(st management.SetupStateResponse) bool { + return st.Active && st.DesiredEngine != "" && !st.EngineInstalled && st.InstallClaimed == "" +} diff --git a/cmd/waired/setup_install_test.go b/cmd/waired/setup_install_test.go new file mode 100644 index 000000000..1b9e8c2b2 --- /dev/null +++ b/cmd/waired/setup_install_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "errors" + "io" + "strings" + "sync" + "testing" + + "github.com/waired-ai/waired-agent/internal/management" + "github.com/waired-ai/waired-agent/internal/setup" +) + +// fakeEngineInstaller records install attempts without downloading a +// multi-GB engine. +type fakeEngineInstaller struct { + mu sync.Mutex + calls []string // stateDir per call + handed []string // stateDir passed to the ownership handoff + err error + detected setup.OllamaDetection +} + +// install swaps in the seams for the duration of one test and returns +// the recorder. +func (f *fakeEngineInstaller) install(t *testing.T) *fakeEngineInstaller { + t.Helper() + prevInstall, prevDetect, prevHand := setupInstallEngine, setupDetectEngine, setupHandState + setupInstallEngine = func(_ bool, stateDir string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, stateDir) + return f.err + } + setupDetectEngine = func(context.Context) setup.OllamaDetection { return f.detected } + setupHandState = func(stateDir string) { + f.mu.Lock() + defer f.mu.Unlock() + f.handed = append(f.handed, stateDir) + } + t.Cleanup(func() { + setupInstallEngine, setupDetectEngine, setupHandState = prevInstall, prevDetect, prevHand + }) + return f +} + +func (f *fakeEngineInstaller) installed() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.calls...) +} + +func (f *fakeEngineInstaller) handedOff() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.handed...) +} + +// activeInstallState is the state a daemon serves when the wizard has +// asked for an engine and nothing has claimed the install yet. +func activeInstallState() management.SetupStateResponse { + return management.SetupStateResponse{ + Active: true, + DesiredEngine: "ollama", + StateDir: "/var/lib/waired", + } +} + +// lastPhase returns the phase of the final lease update, which is what +// the daemon's snapshot reads. +func lastPhase(t *testing.T, d *fakeSetupDaemon) management.SetupExecutorRequest { + t.Helper() + reqs := d.noted() + if len(reqs) == 0 { + t.Fatal("executor sent no lease updates") + } + return reqs[len(reqs)-1] +} + +// TestSetupEngineInstallHappyPath is the core of waired#835 §11: on the +// daemon path nothing else can install an engine, so this is what turns +// the wizard's first step green. +func TestSetupEngineInstallHappyPath(t *testing.T) { + shrinkSetupTimers(t) + f := (&fakeEngineInstaller{}).install(t) + d := &fakeSetupDaemon{} + d.setState(activeInstallState()) + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + setupEngineInstall(context.Background(), s, io.Discard, "linux", true) + + if got := f.installed(); len(got) != 1 || got[0] != "/var/lib/waired" { + t.Fatalf("installer calls = %v, want one call with the daemon's state dir", got) + } + // #484: extracted as root, so the unprivileged daemon cannot read it + // back without this. + if got := f.handedOff(); len(got) != 1 || got[0] != "/var/lib/waired" { + t.Fatalf("ownership handoff = %v, want one call with the state dir", got) + } + if last := lastPhase(t, d); last.Phase != management.SetupExecutorPhaseDone { + t.Fatalf("final phase = %q, want done", last.Phase) + } +} + +// TestSetupEngineInstallClaimsBeforeInstalling pins the ordering the +// latch depends on: the daemon must see "installing" before the long +// download starts, or a second executor could start a parallel one. +func TestSetupEngineInstallClaimsBeforeInstalling(t *testing.T) { + shrinkSetupTimers(t) + d := &fakeSetupDaemon{} + d.setState(activeInstallState()) + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + + var phaseAtInstall string + f := &fakeEngineInstaller{} + f.install(t) + setupInstallEngine = func(_ bool, _ string) error { + phaseAtInstall = lastPhase(t, d).Phase + return nil + } + setupEngineInstall(context.Background(), s, io.Discard, "linux", true) + + if phaseAtInstall != management.SetupExecutorPhaseInstalling { + t.Fatalf("phase when the install began = %q, want installing", phaseAtInstall) + } +} + +// TestSetupEngineInstallReportsFailure: the executor's own words beat +// any guess the daemon could make, so the wizard shows the real reason +// instead of a generic executor_gone. +func TestSetupEngineInstallReportsFailure(t *testing.T) { + shrinkSetupTimers(t) + (&fakeEngineInstaller{err: errors.New("no space left on device")}).install(t) + d := &fakeSetupDaemon{} + d.setState(activeInstallState()) + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + setupEngineInstall(context.Background(), s, io.Discard, "linux", true) + + last := lastPhase(t, d) + if last.Phase != management.SetupExecutorPhaseFailed { + t.Fatalf("final phase = %q, want failed", last.Phase) + } + if !strings.Contains(last.Error, "no space left on device") { + t.Fatalf("error detail = %q, want the installer's message", last.Error) + } +} + +// TestSetupEngineInstallSkipConditions covers every reason not to +// install. Each one must leave the installer untouched — an accidental +// install here is a multi-GB download nobody asked for. +func TestSetupEngineInstallSkipConditions(t *testing.T) { + tests := []struct { + name string + state management.SetupStateResponse + }{ + {"no setup running", management.SetupStateResponse{DesiredEngine: "ollama", StateDir: "/s"}}, + {"no engine desired", management.SetupStateResponse{Active: true, StateDir: "/s"}}, + { + "engine already installed", + management.SetupStateResponse{Active: true, DesiredEngine: "ollama", EngineInstalled: true, StateDir: "/s"}, + }, + { + "another executor holds the claim", + management.SetupStateResponse{Active: true, DesiredEngine: "ollama", InstallClaimed: "ollama", StateDir: "/s"}, + }, + { + "vllm is not ours to install", + management.SetupStateResponse{Active: true, DesiredEngine: "vllm", StateDir: "/s"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + shrinkSetupTimers(t) + f := (&fakeEngineInstaller{}).install(t) + d := &fakeSetupDaemon{} + d.setState(tc.state) + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + setupEngineInstall(context.Background(), s, io.Discard, "linux", true) + + if got := f.installed(); len(got) != 0 { + t.Fatalf("installed %v, want no install", got) + } + }) + } +} + +// TestSetupEngineInstallWithoutStateDirRefuses: guessing a path would +// present as an install that "worked" and a step that never turns +// green, which is the worst outcome available here. +func TestSetupEngineInstallWithoutStateDirRefuses(t *testing.T) { + shrinkSetupTimers(t) + f := (&fakeEngineInstaller{}).install(t) + d := &fakeSetupDaemon{} + d.setState(management.SetupStateResponse{Active: true, DesiredEngine: "ollama"}) + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + setupEngineInstall(context.Background(), s, io.Discard, "linux", true) + + if got := f.installed(); len(got) != 0 { + t.Fatalf("installed %v with no state dir, want refusal", got) + } + if last := lastPhase(t, d); last.Phase != management.SetupExecutorPhaseFailed { + t.Fatalf("final phase = %q, want failed", last.Phase) + } +} + +// TestSetupEngineInstallPerOS is the cross-OS parity table. It also +// pins the two skip reasons that must report through the lease rather +// than dying silently: an unelevated executor and an opt-out host. +func TestSetupEngineInstallPerOS(t *testing.T) { + tests := []struct { + name string + goos string + elevated bool + optOut bool + detected setup.OllamaDetection + wantInstall bool + wantPhase string + wantDetail string + }{ + { + name: "linux elevated installs", goos: "linux", elevated: true, + wantInstall: true, wantPhase: management.SetupExecutorPhaseDone, + }, + { + name: "linux unelevated reports permission", goos: "linux", + wantPhase: management.SetupExecutorPhaseFailed, wantDetail: "administrator privileges", + }, + { + name: "windows elevated installs", goos: "windows", elevated: true, + wantInstall: true, wantPhase: management.SetupExecutorPhaseDone, + }, + { + name: "windows unelevated reports permission", goos: "windows", + wantPhase: management.SetupExecutorPhaseFailed, wantDetail: "administrator privileges", + }, + { + // /Applications is admin-group-writable, so macOS attempts + // the install and lets it fail with a real message. + name: "darwin installs unelevated", goos: "darwin", + wantInstall: true, wantPhase: management.SetupExecutorPhaseDone, + }, + { + name: "darwin with its own engine is already done", goos: "darwin", + detected: setup.OllamaDetection{Installed: true, Path: "/Applications/Ollama.app"}, + wantPhase: management.SetupExecutorPhaseDone, + }, + { + name: "opt-out refuses and says why", goos: "linux", elevated: true, optOut: true, + wantPhase: management.SetupExecutorPhaseFailed, wantDetail: "WAIRED_NO_OLLAMA", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + shrinkSetupTimers(t) + if tc.optOut { + t.Setenv("WAIRED_NO_OLLAMA", "1") + } + f := (&fakeEngineInstaller{detected: tc.detected}).install(t) + d := &fakeSetupDaemon{} + d.setState(activeInstallState()) + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + setupEngineInstall(context.Background(), s, io.Discard, tc.goos, tc.elevated) + + if got := len(f.installed()) > 0; got != tc.wantInstall { + t.Fatalf("installed = %v, want %v", got, tc.wantInstall) + } + last := lastPhase(t, d) + if last.Phase != tc.wantPhase { + t.Fatalf("final phase = %q, want %q", last.Phase, tc.wantPhase) + } + if tc.wantDetail != "" && !strings.Contains(last.Error, tc.wantDetail) { + t.Fatalf("error detail = %q, want it to mention %q", last.Error, tc.wantDetail) + } + }) + } +} + +// TestSetupEngineInstallOlderDaemonIsInert is the acceptance-item-12 +// bar: against a daemon without the executor routes, this must do +// nothing at all rather than install off its own guess. +func TestSetupEngineInstallOlderDaemonIsInert(t *testing.T) { + shrinkSetupTimers(t) + f := (&fakeEngineInstaller{}).install(t) + d := &fakeSetupDaemon{notFound: true} + srv := d.server(t) + + s := attachSetupExecutor(srv.URL, true) + defer s.Release() + setupEngineInstall(context.Background(), s, io.Discard, "linux", true) + + if got := f.installed(); len(got) != 0 { + t.Fatalf("installed %v against an older daemon, want nothing", got) + } +} + +func TestSetupEngineInstallWanted(t *testing.T) { + tests := []struct { + name string + st management.SetupStateResponse + want bool + }{ + {"wanted", activeInstallState(), true}, + {"inactive", management.SetupStateResponse{DesiredEngine: "ollama"}, false}, + {"no desire", management.SetupStateResponse{Active: true}, false}, + {"present", management.SetupStateResponse{Active: true, DesiredEngine: "ollama", EngineInstalled: true}, false}, + {"claimed", management.SetupStateResponse{Active: true, DesiredEngine: "ollama", InstallClaimed: "ollama"}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := setupEngineInstallWanted(tc.st); got != tc.want { + t.Fatalf("setupEngineInstallWanted = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/docs-site/src/content/docs/getting-started/first-run.mdx b/docs-site/src/content/docs/getting-started/first-run.mdx index bb9a3fdb2..9ee81806c 100644 --- a/docs-site/src/content/docs/getting-started/first-run.mdx +++ b/docs-site/src/content/docs/getting-started/first-run.mdx @@ -47,7 +47,14 @@ Waired network with `waired init`. Run it once per machine. ``` **Leave that terminal open** while the browser finishes — some steps, such - as installing the AI software, need it. Pressing **Enter** hands control + as installing the AI software, need it. That one runs right there in the + terminal, and it is the slowest part of a first run: + + ```text + 📦 Installing the AI engine for the setup in your browser (one-time download)... + ``` + + Pressing **Enter** hands control back to the terminal and it asks the remaining questions itself; the browser page then tells you what to run here. Closing the terminal is safe: your progress is saved, and downloads keep going either way. diff --git a/docs-site/src/content/docs/ja/getting-started/first-run.mdx b/docs-site/src/content/docs/ja/getting-started/first-run.mdx index dbec9d792..88a10c501 100644 --- a/docs-site/src/content/docs/ja/getting-started/first-run.mdx +++ b/docs-site/src/content/docs/ja/getting-started/first-run.mdx @@ -47,7 +47,14 @@ import { Steps, Tabs, TabItem, Aside } from '@astrojs/starlight/components'; ``` ブラウザ側が終わるまで**そのターミナルは閉じずに開いたままにしてください** — - AI ソフトウェアの導入など、ターミナルを必要とする手順があります。**Enter** を + AI ソフトウェアの導入など、ターミナルを必要とする手順があります。その導入は + ターミナル側で実行され、初回セットアップで最も時間がかかる部分です。 + + ```text + 📦 Installing the AI engine for the setup in your browser (one-time download)... + ``` + + **Enter** を 押すとターミナル側に主導権が戻り、残りの質問はターミナルが尋ねます(ブラウザ側は ここで実行すべきコマンドを案内します)。ターミナルを閉じても安全です。進捗は 保存され、ダウンロードはどちらの場合も続きます。 diff --git a/internal/management/setup_handlers.go b/internal/management/setup_handlers.go index a5fee34c7..da48f65ab 100644 --- a/internal/management/setup_handlers.go +++ b/internal/management/setup_handlers.go @@ -57,6 +57,21 @@ type SetupStateResponse struct { // "re-run sudo waired init" recovery path actually recovers // (waired#835 §11.1). InstallClaimed string `json:"install_claimed,omitempty"` + // StateDir is the daemon's own state directory — where an executor + // must put a bundled engine so this daemon can find it again. The + // daemon declares it and the executor obeys rather than recomputing + // it, because a CLI-side defaultStateDir() silently diverges from a + // daemon started with --state-dir or $WAIRED_STATE_DIR, and the + // symptom of divergence is silent: the install succeeds, the daemon + // looks elsewhere, and engine_install spins forever (waired#835 + // §11.1). Empty before enrollment or with inference off, which the + // executor must read as "do not install" — never as "guess". + // + // This does not weaken §17.1's no-paths-on-the-wire rule: that rule + // governs values crossing the control-plane trust boundary, and this + // is a daemon's own value returned to a co-local process that could + // already compute it. + StateDir string `json:"state_dir,omitempty"` } // SetupExecutorRequest is the body of POST /waired/v1/setup/executor: diff --git a/internal/management/setup_handlers_test.go b/internal/management/setup_handlers_test.go index d6c872260..948ccb080 100644 --- a/internal/management/setup_handlers_test.go +++ b/internal/management/setup_handlers_test.go @@ -61,6 +61,7 @@ func TestSetupStateHandler(t *testing.T) { DesiredModelID: "m-1", EngineInstalled: true, InstallClaimed: "ollama", + StateDir: "/var/lib/waired", }} srv := New(fakeStatus{}, fakePinger{}).WithSetupExecutor(f) @@ -76,6 +77,11 @@ func TestSetupStateHandler(t *testing.T) { if !got.Active || got.DesiredEngine != "ollama" || got.InstallClaimed != "ollama" { t.Fatalf("state = %+v, want the scripted projection", got) } + // The executor installs relative to this; a dropped field would send + // the engine somewhere the daemon never looks (waired#835 §11.1). + if got.StateDir != "/var/lib/waired" { + t.Fatalf("state = %+v, want the daemon's state dir on the wire", got) + } rec = httptest.NewRecorder() srv.mux().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/waired/v1/setup/state", nil))