diff --git a/cmd/agentop/cmd_config_migrate.go b/cmd/agentop/cmd_config_migrate.go index e7c7f138b..c60930187 100644 --- a/cmd/agentop/cmd_config_migrate.go +++ b/cmd/agentop/cmd_config_migrate.go @@ -85,13 +85,9 @@ func migrateConfig(path string, stdout io.Writer) (changed bool, err error) { return false, err } - // Keep the previous file once, under a distinct name so it cannot be confused - // with the config itself. - bak := path + ".before-agentop-migrate" - if _, serr := os.Stat(bak); os.IsNotExist(serr) { - if werr := os.WriteFile(bak, raw, 0o600); werr != nil { - return false, fmt.Errorf("writing %s: %w", bak, werr) - } + bak, err := keepPreviousConfig(path, raw) + if err != nil { + return false, err } if err := replaceConfig(path, updated, nil); err != nil { return false, err @@ -220,6 +216,18 @@ func loadCandidate(updated string) (cfg *config.Config, loaded bool, err error) return cfg, true, nil } +// keepPreviousConfig writes raw to path's .before-agentop-migrate file, unless that +// file exists. The distinct name keeps it apart from the config itself. +func keepPreviousConfig(path string, raw []byte) (bak string, err error) { + bak = path + ".before-agentop-migrate" + if _, serr := os.Stat(bak); os.IsNotExist(serr) { + if werr := os.WriteFile(bak, raw, 0o600); werr != nil { + return "", fmt.Errorf("writing %s: %w", bak, werr) + } + } + return bak, nil +} + // replaceConfig swaps updated in for the file at path, through a temp file that // must load first — and, when verify is set, pass it. A migration that produces an // unparseable config would take the proxy down on next start, so the original @@ -263,22 +271,50 @@ func replaceConfig(path, updated string, verify func(*config.Config) error) erro // insertListenerKeys adds keys to the listener block, matching whatever // indentation the file already uses. +func insertListenerKeys(src string, pins []pinnedListener) (string, error) { + return insertBlockKeys(src, "listener", pins) +} + +// topLevelKey returns the index of the first top-level line that starts with head, +// or -1, and the indentation of the document (-1 when it has no key line). Only a +// top-level key counts: the same name nested in a plugin's config is another block. +func topLevelKey(lines []string, head string) (at, docIndent int) { + docIndent = -1 + for i, l := range lines { + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || isDocumentMarker(trimmed) { + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + if docIndent < 0 { + docIndent = indent + } + if indent == docIndent && strings.HasPrefix(trimmed, head) { + return i, docIndent + } + } + return -1, docIndent +} + +// isDocumentMarker reports whether trimmed is a "---" document start, a "..." +// document end or a "%" directive. None of them is a key line. +func isDocumentMarker(trimmed string) bool { + return trimmed == "---" || trimmed == "..." || strings.HasPrefix(trimmed, "--- ") || strings.HasPrefix(trimmed, "%") +} + +// insertBlockKeys adds keys to the block named block, matching whatever +// indentation the file already uses. A pin with no comment gets no comment line. // // Indentation is read from the file rather than assumed: a real config turned up // with the whole document indented two spaces, which is valid YAML and would have // produced a broken file from any hardcoded guess. -func insertListenerKeys(src string, pins []pinnedListener) (string, error) { +func insertBlockKeys(src, block string, pins []pinnedListener) (string, error) { lines := strings.Split(src, "\n") + head := block + ":" - listenerAt := -1 - for i, l := range lines { - if strings.HasPrefix(strings.TrimSpace(l), "listener:") && !strings.HasPrefix(strings.TrimSpace(l), "#") { - listenerAt = i - break - } - } + listenerAt, _ := topLevelKey(lines, head) if listenerAt < 0 { - return "", fmt.Errorf("no listener: block found") + return "", fmt.Errorf("no %s block found", head) } parentIndent := len(lines[listenerAt]) - len(strings.TrimLeft(lines[listenerAt], " ")) @@ -306,9 +342,9 @@ func insertListenerKeys(src string, pins []pinnedListener) (string, error) { // is invalid YAML. That fails closed — the validation before the rename catches // it and the original survives — but the user would see a bare parse error // instead of being told the shape is unsupported. - if rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[listenerAt]), "listener:")); rest != "" { - return "", fmt.Errorf("listener: is written in flow style (%s); "+ - "rewrite it as an indented block, or add the keys by hand", rest) + if rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[listenerAt]), head)); rest != "" && !strings.HasPrefix(rest, "#") { + return "", fmt.Errorf("%s is written in flow style (%s); "+ + "rewrite it as an indented block, or add the keys by hand", head, rest) } // A genuinely empty block: indent one level in from the parent. childIndent = parentIndent + 2 @@ -323,8 +359,10 @@ func insertListenerKeys(src string, pins []pinnedListener) (string, error) { pad := strings.Repeat(" ", childIndent) add := make([]string, 0, len(pins)*3) for _, pin := range pins { - for _, cl := range strings.Split(pin.comment, "\n") { - add = append(add, pad+"# "+strings.TrimSpace(cl)) + if pin.comment != "" { + for _, cl := range strings.Split(pin.comment, "\n") { + add = append(add, pad+"# "+strings.TrimSpace(cl)) + } } add = append(add, pad+pin.key+": "+pin.value) } diff --git a/cmd/agentop/cmd_script.go b/cmd/agentop/cmd_script.go index 45eb4218c..d0d51f4d1 100644 --- a/cmd/agentop/cmd_script.go +++ b/cmd/agentop/cmd_script.go @@ -582,6 +582,9 @@ func scriptInstall(args []string, stdout, stderr io.Writer) int { return 2 } t, err := loadScriptTarget(cfgPath) + if err == nil { + err = praxisRefusal(t.cfg, t.path, t.statsURL) + } if err != nil { return fail("%v", err) } @@ -723,6 +726,9 @@ func scriptRemove(args []string, stdout, stderr io.Writer) int { return 2 } t, err := loadScriptTarget(cfgPath) + if err == nil { + err = praxisRefusal(t.cfg, t.path, t.statsURL) + } if err != nil { return fail("%v", err) } diff --git a/cmd/agentop/cmd_server.go b/cmd/agentop/cmd_server.go index e77ad2b18..78f315f2b 100644 --- a/cmd/agentop/cmd_server.go +++ b/cmd/agentop/cmd_server.go @@ -154,6 +154,19 @@ func serverTarget(configPath string) (cfg *config.Config, path, statsURL string, return cfg, path, statsURL, nil } +// serverWriteTarget is serverTarget for an action that changes the router. It +// refuses in praxis mode, where the change has no effect. A list does not refuse: +// it shows the file. +func serverWriteTarget(configPath string) (cfg *config.Config, path, statsURL string, err error) { + if cfg, path, statsURL, err = serverTarget(configPath); err == nil { + err = praxisRefusal(cfg, path, statsURL) + } + if err != nil { + return nil, "", "", err + } + return cfg, path, statsURL, nil +} + // readRouter is the router entry's config, decoded without the plugin's validation // so a listing still works on a config the proxy would refuse. present is false // when the outbound chain has no router entry. @@ -212,7 +225,7 @@ func serverList(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "agentop server: unexpected argument %q; the action comes first: agentop server [flags]\n", pos[0]) return 2 } - cfg, path, _, err := serverTarget(*cfgPath) + cfg, path, statsURL, err := serverTarget(*cfgPath) if err != nil { fmt.Fprintf(stderr, "agentop server: %v\n", err) return 1 @@ -223,6 +236,10 @@ func serverList(args []string, stdout, stderr io.Writer) int { return 1 } if len(c.Servers) == 0 { + if praxisRefusal(cfg, path, statsURL) != nil { + fmt.Fprintln(stdout, "No inference servers yet. Praxis mode is on, so `agentop server add` has no effect.") + return 0 + } fmt.Fprintln(stdout, "No inference servers yet. Add one:") fmt.Fprintln(stdout, " agentop server add ") return 0 diff --git a/cmd/agentop/cmd_server_write.go b/cmd/agentop/cmd_server_write.go index b30c75378..dbba7fea0 100644 --- a/cmd/agentop/cmd_server_write.go +++ b/cmd/agentop/cmd_server_write.go @@ -121,7 +121,7 @@ func serverAdd(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } - cfg, path, statsURL, err := serverTarget(*cfgPath) + cfg, path, statsURL, err := serverWriteTarget(*cfgPath) if err != nil { fmt.Fprintf(stderr, "agentop server add: %v\n", err) return 1 @@ -235,7 +235,7 @@ func serverRemove(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "agentop server remove: %v\n", err) return 2 } - cfg, path, statsURL, err := serverTarget(*cfgPath) + cfg, path, statsURL, err := serverWriteTarget(*cfgPath) if err != nil { fmt.Fprintf(stderr, "agentop server remove: %v\n", err) return 1 @@ -400,7 +400,7 @@ func serverUse(args []string, stdout, stderr io.Writer) int { // The canonical spelling, so the key this command writes and the messages it // prints name the agent today's requests report, however the user spelled it. *agent = pipeline.CanonicalAgent(*agent) - cfg, path, statsURL, err := serverTarget(*cfgPath) + cfg, path, statsURL, err := serverWriteTarget(*cfgPath) if err != nil { fmt.Fprintf(stderr, "agentop server use: %v\n", err) return 1 @@ -442,7 +442,7 @@ func serverReset(args []string, stdout, stderr io.Writer) int { // The canonical spelling, for the same reason as use: the check below and the // key the reset removes must name the agent the router routes. *agent = pipeline.CanonicalAgent(*agent) - cfg, path, statsURL, err := serverTarget(*cfgPath) + cfg, path, statsURL, err := serverWriteTarget(*cfgPath) if err != nil { fmt.Fprintf(stderr, "agentop server reset: %v\n", err) return 1 @@ -569,7 +569,7 @@ func serverMove(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "agentop server move: %v\n", err) return 2 } - cfg, path, statsURL, err := serverTarget(*cfgPath) + cfg, path, statsURL, err := serverWriteTarget(*cfgPath) if err != nil { fmt.Fprintf(stderr, "agentop server move: %v\n", err) return 1 diff --git a/cmd/agentop/cmd_service.go b/cmd/agentop/cmd_service.go index 3fc8b5de8..14ef4b557 100644 --- a/cmd/agentop/cmd_service.go +++ b/cmd/agentop/cmd_service.go @@ -1207,6 +1207,11 @@ func serviceIsCurrent(goos string, p servicePaths) bool { if goos == "darwin" && !strings.Contains(body, "--supervise") { return false } + // On Linux the unit supervises only in praxis mode. A unit that does not agree + // with the config file must be written again. + if goos == "linux" && strings.Contains(body, "--supervise") != configPraxisEnabled(p.configFile) { + return false + } if running, _ := supervisorRunning(goos, p); !running { return false } @@ -1216,6 +1221,13 @@ func serviceIsCurrent(goos string, p servicePaths) bool { return waitHealthy(p.healthURL, 2*time.Second) } +// configPraxisEnabled reports whether the config file at path sets praxis.enabled. +// It is false when the file does not load. +func configPraxisEnabled(path string) bool { + cfg, err := config.Load(path) + return err == nil && cfg.PraxisEnabled() +} + // proxyBinaryUnchanged reports whether the binary on disk is still the one the unit // was written for. // diff --git a/cmd/agentop/cmd_service_goos_test.go b/cmd/agentop/cmd_service_goos_test.go index b637a09f1..2e4a9ec37 100644 --- a/cmd/agentop/cmd_service_goos_test.go +++ b/cmd/agentop/cmd_service_goos_test.go @@ -114,6 +114,35 @@ func TestServiceIsCurrent_BothPlatforms(t *testing.T) { } }) + // In praxis mode the systemd unit must supervise. This clause alone makes install + // and setup write the unit again after praxis.enabled changes. + t.Run("linux: praxis on and a unit without --supervise is not current", func(t *testing.T) { + p := currentServiceFixture(t, "linux") + if err := os.WriteFile(p.configFile, []byte("praxis:\n enabled: true\n"), 0o600); err != nil { + t.Fatal(err) + } + if serviceIsCurrent("linux", p) { + t.Error("claimed current for a direct-mode unit under a praxis config") + } + }) + + t.Run("linux: praxis on and a supervising unit is current", func(t *testing.T) { + p := currentServiceFixture(t, "linux") + if err := os.WriteFile(p.configFile, []byte("praxis:\n enabled: true\n"), 0o600); err != nil { + t.Fatal(err) + } + unit := renderUnitFor("linux", p) + if !strings.Contains(unit, "--supervise") { + t.Fatal("a supervising systemd unit has no --supervise; this case says nothing") + } + if err := os.WriteFile(p.unitFile, []byte(unit), 0o600); err != nil { + t.Fatal(err) + } + if !serviceIsCurrent("linux", p) { + t.Error("not current; every Linux praxis install would reinstall at each run") + } + }) + // The supervisor clause, driven through each platform's own stub. "Installed but // not running" is the state a bad config leaves, and reporting it as current means // an install that skips the repair. diff --git a/cmd/agentop/cmd_service_platform.go b/cmd/agentop/cmd_service_platform.go index 40a8ee449..ea32ad6f6 100644 --- a/cmd/agentop/cmd_service_platform.go +++ b/cmd/agentop/cmd_service_platform.go @@ -90,8 +90,11 @@ func shQuote(v string) string { // KeepAlive stays in the plist for the cases launchd does honour, such as a // login-time load; it is no longer what crash recovery depends on. // -// The systemd unit deliberately does NOT use --supervise: Restart=on-failure works +// In direct mode the systemd unit does NOT use --supervise: Restart=on-failure works // there and counts restarts against StartLimit, which a nested supervisor would hide. +// In praxis mode (praxis.enabled in p.configFile) it uses --supervise, because only +// the supervisor starts cortex-gateway and writes gateway.token. The unit reads the +// file itself, so no caller can write a unit that does not agree with it. func renderUnitFor(goos string, p servicePaths) string { if goos == "darwin" { // HOME is set explicitly: the config interpolates ${HOME} at load, and an @@ -148,7 +151,11 @@ func renderUnitFor(goos string, p servicePaths) string { // proxy closes its listeners on SIGTERM rather than draining them (cmd/cortex/main.go) // and is gone in well under a second, so this only ever ends one that hangs — which // systemd's own default would let hold a restart for 90s. There is no supervisor in - // between here: KillMode=control-group signals the proxy directly. + // between here in direct mode: KillMode=control-group signals the proxy directly. + supervise := "" + if configPraxisEnabled(p.configFile) { + supervise = " --supervise" + } return `[Unit] Description=Cortex local proxy (cortex) Documentation=https://github.com/rossoctl/cortex @@ -158,7 +165,7 @@ StartLimitBurst=5 [Service] Type=simple -ExecStart=` + shQuote(p.binary) + ` --config ` + shQuote(p.configFile) + ` +ExecStart=` + shQuote(p.binary) + supervise + ` --config ` + shQuote(p.configFile) + ` Restart=on-failure RestartSec=10 TimeoutStopSec=5 diff --git a/cmd/agentop/cmd_service_test.go b/cmd/agentop/cmd_service_test.go index d6e6ef213..90ceeac9c 100644 --- a/cmd/agentop/cmd_service_test.go +++ b/cmd/agentop/cmd_service_test.go @@ -802,3 +802,69 @@ func TestReportNotAnswering_LeadsWithTheHolder(t *testing.T) { t.Errorf("the holder line is not between those two:\n%s", s) } } + +// TestRenderUnit_LinuxPraxisSupervises: in praxis mode the supervisor must start the +// gateway and write gateway.token, so the systemd unit runs cortex --supervise. The +// unit reads praxis.enabled from the config file: no caller sets it. +func TestRenderUnit_LinuxPraxisSupervises(t *testing.T) { + p := servicePathsFixture(t) + if err := os.WriteFile(p.configFile, []byte("praxis:\n enabled: true\n"), 0o600); err != nil { + t.Fatal(err) + } + u := renderUnitFor("linux", p) + want := "ExecStart=" + shQuote(p.binary) + " --supervise --config " + shQuote(p.configFile) + "\n" + if !strings.Contains(u, want) { + t.Errorf("ExecStart is not %q:\n%s", want, u) + } +} + +// TestRenderUnit_LinuxDirectUnchanged: direct mode keeps the unit it had before +// praxis mode, byte for byte. +func TestRenderUnit_LinuxDirectUnchanged(t *testing.T) { + p := servicePathsFixture(t) + want := `[Unit] +Description=Cortex local proxy (cortex) +Documentation=https://github.com/rossoctl/cortex +X-AgentopVersion=` + version + ` +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +ExecStart=` + shQuote(p.binary) + ` --config ` + shQuote(p.configFile) + ` +Restart=on-failure +RestartSec=10 +TimeoutStopSec=5 +StandardOutput=append:` + p.logFile + ` +StandardError=append:` + p.logFile + ` + +[Install] +WantedBy=default.target +` + if got := renderUnitFor("linux", p); got != want { + t.Errorf("direct-mode unit changed:\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestConfigPraxisEnabled(t *testing.T) { + dir := t.TempDir() + for _, tc := range []struct { + name, body string + want bool + }{ + {"on", "praxis:\n enabled: true\n", true}, + {"off", "praxis:\n enabled: false\n", false}, + {"broken", "praxis: [\n", false}, + } { + path := filepath.Join(dir, tc.name+".yaml") + if err := os.WriteFile(path, []byte(tc.body), 0o600); err != nil { + t.Fatal(err) + } + if got := configPraxisEnabled(path); got != tc.want { + t.Errorf("%s: configPraxisEnabled = %v, want %v", tc.name, got, tc.want) + } + } + if configPraxisEnabled(filepath.Join(dir, "missing.yaml")) { + t.Error("missing file: configPraxisEnabled = true, want false") + } +} diff --git a/cmd/agentop/cmd_setup.go b/cmd/agentop/cmd_setup.go index 6ac9c0076..4a426a180 100644 --- a/cmd/agentop/cmd_setup.go +++ b/cmd/agentop/cmd_setup.go @@ -21,7 +21,7 @@ const setupUsage = `agentop setup — install or repair Cortex on this machine, Usage: agentop setup [--from DIR] [--claude-code] [--yes] [--no-service] - [--install-only] [--no-modify-path] [--restart] + [--install-only] [--no-modify-path] [--restart] [--praxis] Plans every change first and changes nothing until you agree; a failure or a Ctrl-C undoes what it did. Without --from it repairs what is installed. @@ -34,6 +34,7 @@ Ctrl-C undoes what it did. Without --from it repairs what is installed. --install-only install the binaries and PATH only --no-modify-path never edit a shell profile --restart restart the service even when nothing changed + --praxis turn on praxis mode (needs cortex-gateway) Exit status: 0 done or already current, 1 failed (and rolled back) or refused before any change, 2 usage, 3 declined, interrupted before any change, or no @@ -109,6 +110,7 @@ func parseSetupFlags(args []string, stderr io.Writer) (setupOptions, error) { fs.BoolVar(&o.installOnly, "install-only", false, "") fs.BoolVar(&o.noModifyPath, "no-modify-path", false, "") fs.BoolVar(&o.restart, "restart", false, "") + fs.BoolVar(&o.praxis, "praxis", false, "") fs.Int64Var(&o.handoffBytes, "handoff-bytes", 0, "") fs.IntVar(&o.handoffSeconds, "handoff-seconds", 0, "") _ = fs.Bool("local", false, "") // install.sh passes it; local is the only mode diff --git a/cmd/agentop/cmd_tools.go b/cmd/agentop/cmd_tools.go index 2cb9a962b..cf3433c24 100644 --- a/cmd/agentop/cmd_tools.go +++ b/cmd/agentop/cmd_tools.go @@ -122,6 +122,16 @@ func runTools(args []string, stdout, stderr io.Writer) int { window = toolscan.AllTime } + // A file that does not load keeps today's path: PatchConfig reports the error. + if *write != "" { + if cfg, path, statsURL, err := serverTarget(*write); err == nil { + if err := praxisRefusal(cfg, path, statsURL); err != nil { + fmt.Fprintf(stderr, "agentop: %v\n", err) + return 1 + } + } + } + scanDir := *dir if scanDir == "" { d, err := toolscan.DefaultProjectsDir() diff --git a/cmd/agentop/cmd_update.go b/cmd/agentop/cmd_update.go index bdfd49a30..fa16703f7 100644 --- a/cmd/agentop/cmd_update.go +++ b/cmd/agentop/cmd_update.go @@ -23,8 +23,9 @@ Usage: Runs the same installer as the one-liner. The installer downloads and verifies the release, then gives it to agentop setup. Update keeps these as they are now: Claude Code's routing, a proxy with no service (--no-service), the cpex -build of cortex, an install with no config (--install-only), and a shell -profile the install did not edit (--no-modify-path). +build of cortex, praxis mode (--praxis), an install with no config +(--install-only), and a shell profile the install did not edit +(--no-modify-path). --ref=REF install REF instead of the newest stable release, e.g. --ref=v0.9.0-rc.3 for a release candidate, --ref=main @@ -98,8 +99,9 @@ func runUpdate(args []string, stdout, stderr io.Writer) int { // updateFlags are the installer flags that keep this install as it is: without // them the installer would unroute nothing but put a service in place of a -// background proxy, the default cortex in place of the cpex one, and a full setup -// in place of an install-only one, and would edit a profile the install left alone. +// background proxy, the default cortex in place of the cpex one, direct mode in +// place of praxis mode, and a full setup in place of an install-only one, and would +// edit a profile the install left alone. func updateFlags(env *setupEnv, ref string, yes bool) []string { var flags []string if !fileExists(env.configPath()) { // install-only writes no config @@ -120,6 +122,9 @@ func updateFlags(env *setupEnv, ref string, yes bool) []string { if installedIsCpex(filepath.Join(env.binDir, proxyBinary)) { flags = append(flags, "--cpex") } + if configPraxisEnabled(env.configPath()) { + flags = append(flags, "--praxis") + } if ref != "" { flags = append(flags, "--ref="+ref) } diff --git a/cmd/agentop/cmd_update_test.go b/cmd/agentop/cmd_update_test.go index fa6aa87fb..d63140207 100644 --- a/cmd/agentop/cmd_update_test.go +++ b/cmd/agentop/cmd_update_test.go @@ -9,6 +9,8 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" + "slices" "strings" "syscall" "testing" @@ -213,3 +215,38 @@ func TestUpdateKeepsTheProfileAsItIs(t *testing.T) { t.Errorf("flags %q on PATH, want none", got) } } + +// praxisUpdateFlags writes body as the config of a new install and returns the +// update flags for it. +func praxisUpdateFlags(t *testing.T, body string) []string { + t.Helper() + home := t.TempDir() + env := &setupEnv{home: home, goos: runtime.GOOS, binDir: filepath.Join(home, ".local", "bin"), cortexDir: filepath.Join(home, ".cortex")} + env.pathEnv = env.binDir + if err := os.MkdirAll(env.cortexDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.configPath(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return updateFlags(env, "", false) +} + +func TestUpdateFlags_PraxisConfigPassesPraxis(t *testing.T) { + if got := praxisUpdateFlags(t, "praxis:\n enabled: true\n"); !slices.Contains(got, "--praxis") { + t.Errorf("flags %q, want --praxis", got) + } +} + +func TestUpdateFlags_DirectConfigNoPraxis(t *testing.T) { + if got := praxisUpdateFlags(t, "praxis:\n enabled: false\n"); slices.Contains(got, "--praxis") { + t.Errorf("flags %q, want no --praxis", got) + } +} + +// A config that does not load says nothing about the mode, so update passes nothing. +func TestUpdateFlags_PraxisConfigBrokenPassesNothing(t *testing.T) { + if got := praxisUpdateFlags(t, "praxis: [\n enabled: true\n"); slices.Contains(got, "--praxis") { + t.Errorf("flags %q, want no --praxis", got) + } +} diff --git a/cmd/agentop/local_endpoint.go b/cmd/agentop/local_endpoint.go index 70ca5c2fc..1e35f0f92 100644 --- a/cmd/agentop/local_endpoint.go +++ b/cmd/agentop/local_endpoint.go @@ -235,7 +235,8 @@ const defaultCortexStatsURL = "http://localhost:47602/" // errNoRunningCortex means nothing answered at the stats URL. var errNoRunningCortex = errors.New("no Cortex is running on this machine") -// runningConfigFields holds the only /config fields that agentop exec reads. +// runningConfigFields holds the only /config fields that agentop exec and +// praxisRefusal read. // A decode into the full config.Config fails when a newer proxy changes the type // of any field. This struct ignores the fields it does not hold. type runningConfigFields struct { @@ -247,6 +248,9 @@ type runningConfigFields struct { Mode string `json:"mode"` CADir string `json:"ca_dir"` } `json:"tls_bridge"` + Praxis *struct { + Enabled bool `json:"enabled"` + } `json:"praxis"` } // runningConfig returns the config of the Cortex actually running, fetched from @@ -308,6 +312,9 @@ func runningConfig(base string) (*config.Config, error) { if got.TLSBridge != nil { cfg.TLSBridge = &config.TLSBridgeConfig{Mode: got.TLSBridge.Mode, CADir: got.TLSBridge.CADir} } + if got.Praxis != nil && got.Praxis.Enabled { + cfg.Praxis = &config.PraxisConfig{Enabled: true} + } // /config redacts values whose keys end in secret/password/token/key/ // credential (and the "mappings" host→credential block), exempting the // non-secret token forms: *_tokens counts and *_per_token rates. diff --git a/cmd/agentop/local_endpoint_test.go b/cmd/agentop/local_endpoint_test.go index d47e52c6e..588fe4938 100644 --- a/cmd/agentop/local_endpoint_test.go +++ b/cmd/agentop/local_endpoint_test.go @@ -281,7 +281,7 @@ func TestWaitForLocalSessionAPI_SaysWhyItIsWaiting(t *testing.T) { } } -// runningConfig reads only the three fields exec uses. A newer proxy can change the +// runningConfig reads only the four fields agentop uses. A newer proxy can change the // type of a different field, and the decode must not fail because of it. func TestRunningConfig_IgnoresFieldsItDoesNotRead(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -319,3 +319,35 @@ func TestRunningConfig_NoBridgeBlockIsNil(t *testing.T) { t.Errorf("TLSBridge = %+v, want nil", cfg.TLSBridge) } } + +// praxisRefusal reads praxis.enabled from the running Cortex. +func TestRunningConfig_ReadsPraxisEnabled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"listener":{"forward_proxy_addr":"127.0.0.1:47600"},"praxis":{"enabled":true}}`) + })) + t.Cleanup(srv.Close) + + cfg, err := runningConfig(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + if !cfg.PraxisEnabled() { + t.Errorf("PraxisEnabled() = false, want true") + } +} + +// A /config without a praxis block gives a nil Praxis. +func TestRunningConfig_NoPraxisBlock(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"listener":{"forward_proxy_addr":"127.0.0.1:47600"}}`) + })) + t.Cleanup(srv.Close) + + cfg, err := runningConfig(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + if cfg.Praxis != nil { + t.Errorf("Praxis = %+v, want nil", cfg.Praxis) + } +} diff --git a/cmd/agentop/praxis_mode.go b/cmd/agentop/praxis_mode.go new file mode 100644 index 000000000..2161a8f29 --- /dev/null +++ b/cmd/agentop/praxis_mode.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + + "github.com/rossoctl/cortex/core/config" +) + +// praxisRefusal returns an error when praxis mode is on in the config file at path +// or in the running Cortex at statsURL. In praxis mode Cortex runs no outbound plugins, so a +// plugin edit has no effect. The file is what the next start runs, and /config is +// what runs now, so either one refuses. statsURL "" skips the /config read, and a +// failed read counts as "not on". +func praxisRefusal(cfg *config.Config, path, statsURL string) error { + inFile := cfg != nil && cfg.PraxisEnabled() + running := false + if statsURL != "" { + if rc, err := runningConfig(statsURL); err == nil { + running = rc.PraxisEnabled() + } + } + var where string + switch { + case inFile && running: + where = "in " + path + " and in the running Cortex" + case inFile: + where = "in " + path + case running: + where = "in the running Cortex" + default: + return nil + } + return fmt.Errorf("praxis mode is on (%s), so Cortex runs no outbound plugins and this command has no effect. To turn it off, set praxis.enabled: false in %s and run: agentop service install --restart", where, path) +} diff --git a/cmd/agentop/praxis_mode_test.go b/cmd/agentop/praxis_mode_test.go new file mode 100644 index 000000000..ff6586e69 --- /dev/null +++ b/cmd/agentop/praxis_mode_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rossoctl/cortex/core/config" +) + +// praxisServer is a running Cortex whose GET /config reports praxis.enabled as on. +func praxisServer(t *testing.T, on bool) string { + t.Helper() + body := `{"listener":{"forward_proxy_addr":"127.0.0.1:47600"}}` + if on { + body = `{"listener":{"forward_proxy_addr":"127.0.0.1:47600"},"praxis":{"enabled":true}}` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// praxisConfigFile writes a config with praxis mode on. Its stats address has no +// listener, so no local Cortex is read. +func praxisConfigFile(t *testing.T) (path, src string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + src = `mode: proxy-sidecar +stats: + address: ` + closedAddr(t) + ` +listener: + bind_loopback_only: true +praxis: + enabled: true +pipeline: + outbound: + plugins: + - name: inference-parser + - name: tool-prune + config: + remove: [] +` + path = filepath.Join(home, "cortex.yaml") + if err := os.WriteFile(path, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + return path, src +} + +func assertFile(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("config changed:\n%s", got) + } +} + +func TestPraxisRefusal_DirectMode(t *testing.T) { + if err := praxisRefusal(&config.Config{}, "/c.yaml", praxisServer(t, false)); err != nil { + t.Errorf("got %v, want nil", err) + } +} + +func TestPraxisRefusal_FileOnly(t *testing.T) { + cfg := &config.Config{Praxis: &config.PraxisConfig{Enabled: true}} + err := praxisRefusal(cfg, "/c.yaml", "") + if err == nil { + t.Fatal("got nil, want a refusal") + } + for _, want := range []string{"praxis mode is on (in /c.yaml)", "set praxis.enabled: false in /c.yaml", "agentop service install --restart"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not contain %q", err, want) + } + } +} + +// The file says direct mode, but the running Cortex still runs praxis mode. +func TestPraxisRefusal_RunningOnly(t *testing.T) { + err := praxisRefusal(&config.Config{}, "/c.yaml", praxisServer(t, true)) + if err == nil || !strings.Contains(err.Error(), "praxis mode is on (in the running Cortex)") { + t.Errorf("got %v, want a refusal that names the running Cortex", err) + } +} + +func TestPraxisRefusal_Both(t *testing.T) { + cfg := &config.Config{Praxis: &config.PraxisConfig{Enabled: true}} + err := praxisRefusal(cfg, "/c.yaml", praxisServer(t, true)) + if err == nil || !strings.Contains(err.Error(), "(in /c.yaml and in the running Cortex)") { + t.Errorf("got %v, want a refusal that names both", err) + } +} + +func TestServerAdd_RefusedInPraxisMode(t *testing.T) { + path, src := praxisConfigFile(t) + stubServerModels(t, testModels, anyModels) + code, _, errOut := runServerCmd(t, "", "add", "ete", "https://ete.example.com", "--main", "opus", "--helper", "haiku", "--yes", "--config", path) + if code != 1 || !strings.Contains(errOut, "praxis mode is on") { + t.Errorf("exit %d, stderr: %s", code, errOut) + } + assertFile(t, path, src) +} + +func TestScriptInstall_RefusedInPraxisMode(t *testing.T) { + path, src := praxisConfigFile(t) + code, _, errOut := runScriptCmd(t, "install", "moca-sandbox", "--config", path) + if code != 1 || !strings.Contains(errOut, "praxis mode is on") { + t.Errorf("exit %d, stderr: %s", code, errOut) + } + assertFile(t, path, src) +} + +// A list changes nothing, so praxis mode does not stop it: it shows the file. +func TestLists_WorkInPraxisMode(t *testing.T) { + path, _ := praxisConfigFile(t) + if code, _, errOut := runScriptCmd(t, "list", "--config", path); code != 0 { + t.Errorf("script list: exit %d, stderr: %s", code, errOut) + } + // The file has no servers, so the list must not suggest a command praxis mode refuses. + if code, out, errOut := runServerCmd(t, "", "--config", path); code != 0 || strings.Contains(errOut, "praxis mode is on") || strings.Contains(out, "Add one") { + t.Errorf("server list: exit %d, stdout: %s, stderr: %s", code, out, errOut) + } +} + +func praxisTranscripts(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "projects") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + writeTranscript(t, dir, "a.jsonl", true) + return dir +} + +func TestToolsScanWrite_RefusedInPraxisMode(t *testing.T) { + path, src := praxisConfigFile(t) + var out, errb bytes.Buffer + if code := runTools([]string{"scan", "--dir", praxisTranscripts(t), "--write", path}, &out, &errb); code != 1 || !strings.Contains(errb.String(), "praxis mode is on") { + t.Errorf("exit %d, stderr: %s", code, errb.String()) + } + assertFile(t, path, src) +} + +// Without --write the scan only reads transcripts, so praxis mode does not stop it. +func TestToolsScan_NoWriteWorksInPraxisMode(t *testing.T) { + praxisConfigFile(t) + var out, errb bytes.Buffer + if code := runTools([]string{"scan", "--dir", praxisTranscripts(t)}, &out, &errb); code != 0 { + t.Errorf("exit %d, stderr: %s", code, errb.String()) + } +} diff --git a/cmd/agentop/setup_praxis.go b/cmd/agentop/setup_praxis.go new file mode 100644 index 000000000..2bbfe6f88 --- /dev/null +++ b/cmd/agentop/setup_praxis.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/rossoctl/cortex/core/config" +) + +// gatewayBinary is the praxis-mode data plane that cortex --supervise runs. +const gatewayBinary = "cortex-gateway" + +// praxisPorts are praxis.listen and praxis.egress at their defaults. The port +// preflight of a fresh install probes them when setup runs with --praxis. +var praxisPorts = []string{"47605", "47606"} + +// praxisHandFix is the fix for a praxis block that enablePraxisText cannot edit. +const praxisHandFix = "write `enabled: true` under praxis: by hand" + +// enablePraxisText sets praxis.enabled to true in src with a line edit, so the +// file keeps its comments and its order. changed is false when src already sets +// it. A flow-style praxis: block is an error. +func enablePraxisText(src string) (out string, changed bool, err error) { + lines := strings.Split(src, "\n") + praxisAt, docIndent := topLevelKey(lines, "praxis:") + if praxisAt < 0 { + pad := strings.Repeat(" ", max(docIndent, 0)) + if src != "" && !strings.HasSuffix(src, "\n") { + src += "\n" + } + return src + pad + "praxis:\n" + pad + " enabled: true\n", true, nil + } + if rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[praxisAt]), "praxis:")); rest != "" && !strings.HasPrefix(rest, "#") { + return "", false, fmt.Errorf("praxis: is written in flow style (%s)", rest) + } + // The block's own keys sit at the indentation of its first child line. + childIndent := -1 + for i := praxisAt + 1; i < len(lines); i++ { + l := lines[i] + trimmed := strings.TrimSpace(l) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + indent := len(l) - len(strings.TrimLeft(l, " ")) + if indent <= docIndent { + break + } + if childIndent < 0 { + childIndent = indent + } + if indent != childIndent || !strings.HasPrefix(trimmed, "enabled:") { + continue + } + head := l[:indent] + "enabled:" + rest := strings.TrimPrefix(trimmed, "enabled:") + ci := strings.Index(rest, "#") + if ci < 0 { + ci = len(rest) + } + if strings.TrimSpace(rest[:ci]) == "true" { + return src, false, nil + } + value := rest[:ci] + ws := value[len(strings.TrimRight(value, " \t")):] // the space before a comment + lines[i] = head + " true" + ws + rest[ci:] + return strings.Join(lines, "\n"), true, nil + } + out, err = insertBlockKeys(src, "praxis", []pinnedListener{{key: "enabled", value: "true"}}) + return out, err == nil, err +} + +// enablePraxis sets praxis.enabled to true in the config at path. It writes only +// a result that loads, turns praxis mode on and passes the checks cortex makes at +// start. Otherwise the file stays as it was. +func enablePraxis(path string) (changed bool, err error) { + raw, err := os.ReadFile(path) //nolint:gosec // the user's config + if err != nil { + return false, err + } + out, changed, err := enablePraxisText(string(raw)) + if err != nil || !changed { + return false, err + } + if _, err := keepPreviousConfig(path, raw); err != nil { + return false, err + } + err = replaceConfig(path, out, func(cfg *config.Config) error { + if !cfg.PraxisEnabled() { + return errors.New("the edit did not turn praxis mode on") + } + // As cortex does before it validates the config. + cfg.Mode = config.ModeProxySidecar + config.ApplyPreset(cfg) + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("praxis mode refuses this config: %w", err) + } + return nil + }) + return err == nil, err +} + +// praxisGatewayProblem is the problem when setup with --praxis has no +// cortex-gateway to install or keep: the staged one under --from, or else the +// installed one. A praxis config with no gateway gives a proxy that answers 503. +func praxisGatewayProblem(env *setupEnv) *problem { + dir := env.binDir + if env.fromDir != "" { + dir = env.fromDir + } + if fileExists(filepath.Join(dir, gatewayBinary)) { + return nil + } + return &problem{reason: gatewayBinary + " is not installed", + fix: []string{"install it with: " + installerOneLiner + " -s -- --praxis"}} +} diff --git a/cmd/agentop/setup_praxis_test.go b/cmd/agentop/setup_praxis_test.go new file mode 100644 index 000000000..74e7a58c9 --- /dev/null +++ b/cmd/agentop/setup_praxis_test.go @@ -0,0 +1,377 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rossoctl/cortex/core/config" +) + +// praxisBase is a config that praxis mode accepts once the block is added. +const praxisBase = "mode: proxy-sidecar\nlistener:\n roles: [forward]\n bind_loopback_only: true\n" + +func loadText(t *testing.T, src string) *config.Config { + t.Helper() + cfg, loaded, err := loadCandidate(src) + if err != nil || !loaded { + t.Fatalf("does not load (err %v):\n%s", err, src) + } + return cfg +} + +func TestEnablePraxisText_AppendsBlock(t *testing.T) { + out, changed, err := enablePraxisText(praxisBase) + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if out != praxisBase+"praxis:\n enabled: true\n" { + t.Errorf("out =\n%s", out) + } + if !loadText(t, out).PraxisEnabled() { + t.Error("praxis mode is not on") + } +} + +func TestEnablePraxisText_NoTrailingNewline(t *testing.T) { + out, _, err := enablePraxisText(strings.TrimSuffix(praxisBase, "\n")) + if err != nil || out != praxisBase+"praxis:\n enabled: true\n" { + t.Errorf("err %v, out =\n%s", err, out) + } +} + +func TestEnablePraxisText_IndentedDocument(t *testing.T) { + src := " mode: proxy-sidecar\n listener:\n roles: [forward]\n bind_loopback_only: true\n" + out, _, err := enablePraxisText(src) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(out, "\n praxis:\n enabled: true\n") { + t.Errorf("out =\n%s", out) + } + if !loadText(t, out).PraxisEnabled() { + t.Error("praxis mode is not on") + } +} + +func TestEnablePraxisText_InsertsEnabled(t *testing.T) { + src := praxisBase + "praxis:\n listen: 127.0.0.1:47605\n" + out, changed, err := enablePraxisText(src) + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + cfg := loadText(t, out) + if !cfg.PraxisEnabled() || cfg.Praxis.Listen != "127.0.0.1:47605" { + t.Errorf("praxis = %+v, out =\n%s", cfg.Praxis, out) + } + if strings.Contains(out, "# ") { + t.Errorf("the insert added a comment line:\n%s", out) + } +} + +func TestEnablePraxis_ReplacesFalseKeepsComment(t *testing.T) { + src := praxisBase + "praxis:\n enabled: false # off for now\n" + out, changed, err := enablePraxisText(src) + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if !strings.Contains(out, "\n enabled: true # off for now\n") { + t.Errorf("out =\n%s", out) + } + if !loadText(t, out).PraxisEnabled() { + t.Error("praxis mode is not on") + } +} + +// An enabled: key nested deeper than the block's own children is not the block's. +func TestEnablePraxisText_IgnoresNestedEnabled(t *testing.T) { + src := praxisBase + "session:\n enabled: false\npraxis:\n listen: 127.0.0.1:47605\n" + out, _, err := enablePraxisText(src) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "session:\n enabled: false\n") || !loadText(t, out).PraxisEnabled() { + t.Errorf("out =\n%s", out) + } +} + +func TestEnablePraxisText_AlreadyOn(t *testing.T) { + src := praxisBase + "praxis:\n enabled: true\n" + out, changed, err := enablePraxisText(src) + if err != nil || changed || out != src { + t.Errorf("changed=%v err=%v out=\n%s", changed, err, out) + } +} + +func TestEnablePraxisText_FlowStyleRefused(t *testing.T) { + if _, _, err := enablePraxisText(praxisBase + "praxis: {enabled: false}\n"); err == nil { + t.Error("a flow-style praxis block raised no error") + } +} + +func TestEnablePraxis_InvalidPraxisConfigLeavesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + src := "mode: proxy-sidecar\nlistener:\n roles: [forward]\n" + if err := os.WriteFile(path, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + if _, err := enablePraxis(path); err == nil || !strings.Contains(err.Error(), "bind_loopback_only") { + t.Errorf("err = %v, want the bind_loopback_only refusal", err) + } + if got, _ := os.ReadFile(path); string(got) != src { + t.Errorf("the file changed:\n%s", got) + } +} + +func TestEnablePraxis_WritesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(praxisBase), 0o600); err != nil { + t.Fatal(err) + } + if changed, err := enablePraxis(path); err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if changed, err := enablePraxis(path); err != nil || changed { + t.Errorf("second run: changed=%v err=%v", changed, err) + } +} + +// praxisEnv is a configEnv with a current config (both migrations applied) and +// cortex-gateway in the bin dir. +func praxisEnv(t *testing.T, src string) (*setupEnv, string) { + t.Helper() + env := configEnv(t) + writeExe(t, filepath.Join(env.binDir, "cortex-gateway"), "#!/bin/sh\n") + if err := os.MkdirAll(env.cortexDir, 0o700); err != nil { + t.Fatal(err) + } + writeExe(t, env.configPath(), src) + if _, err := migrateConfig(env.configPath(), io.Discard); err != nil { + t.Fatal(err) + } + if _, err := migrateBobPricing(env.configPath(), io.Discard); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(env.configPath()) + if err != nil { + t.Fatal(err) + } + return env, string(raw) +} + +func TestConfigStep_PraxisKeepsEverythingElse(t *testing.T) { + src := praxisBase + "pipeline:\n outbound:\n plugins:\n - name: inference-parser\n" + routerBlock + env, before := praxisEnv(t, src) + env.opts.praxis = true + p, prob := configStep{}.plan(env) + if prob != nil || p.done || p.verb != "update" { + t.Fatalf("plan = %+v %v", p, prob) + } + if !env.configPinsPending { + t.Error("the plan does not say the running proxy needs a restart") + } + detail, _, err := configStep{}.apply(env, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(detail, "praxis mode on") || !env.configPinsChanged { + t.Errorf("detail %q, restart %v", detail, env.configPinsChanged) + } + after, _ := os.ReadFile(env.configPath()) + if string(after) != before+"praxis:\n enabled: true\n" { + t.Errorf("before:\n%s\nafter:\n%s", before, after) + } + // A second run finds nothing to do. + if p, prob := (configStep{}).plan(env); prob != nil || !p.done { + t.Errorf("re-plan = %+v %v", p, prob) + } +} + +func TestConfigStep_NoPraxisFlagLeavesBlock(t *testing.T) { + env, before := praxisEnv(t, praxisBase+"praxis:\n enabled: true\n") + if p, prob := (configStep{}).plan(env); prob != nil || !p.done { + t.Fatalf("plan = %+v %v", p, prob) + } + if after, _ := os.ReadFile(env.configPath()); string(after) != before { + t.Errorf("the file changed:\n%s", after) + } +} + +func TestConfigStep_PraxisFlowStyleIsAProblem(t *testing.T) { + env, _ := praxisEnv(t, praxisBase+"praxis: {enabled: false}\n") + env.opts.praxis = true + _, prob := configStep{}.plan(env) + if prob == nil || !strings.Contains(strings.Join(prob.fix, " "), "enabled: true") { + t.Errorf("problem = %+v", prob) + } +} + +func TestConfigStep_PraxisNeedsGateway(t *testing.T) { + env := configEnv(t) + env.fromDir = t.TempDir() + // A gateway in the bin dir does not count when --from stages the binaries. + writeExe(t, filepath.Join(env.binDir, "cortex-gateway"), "#!/bin/sh\n") + env.opts.praxis = true + _, prob := configStep{}.plan(env) + if prob == nil || prob.reason != "cortex-gateway is not installed" || + !strings.Contains(strings.Join(prob.fix, " "), "sh -s -- --praxis") { + t.Fatalf("problem = %+v", prob) + } + writeExe(t, filepath.Join(env.fromDir, "cortex-gateway"), "#!/bin/sh\n") + if _, prob := (configStep{}).plan(env); prob != nil { + t.Errorf("a staged gateway still raised %+v", prob) + } +} + +func TestConfigStep_PraxisFreshConfig(t *testing.T) { + env := configEnv(t) + writeExe(t, filepath.Join(env.binDir, "cortex-gateway"), "#!/bin/sh\n") + env.opts.praxis = true + if p, prob := (configStep{}).plan(env); prob != nil || p.verb != "write" { + t.Fatalf("plan = %+v %v", p, prob) + } + if _, _, err := (configStep{}).apply(env, nil); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(env.configPath()) + if err != nil || !cfg.PraxisEnabled() { + t.Errorf("err %v, praxis %+v", err, cfg) + } +} + +// The Global Constraint: without --praxis, setup needs no cortex-gateway and +// writes no praxis block. +func TestSetup_NoGatewayDirectModeWorks(t *testing.T) { + skipSceneInShort(t) + sc := newSetupScene(t, ok200) + code, out := sc.run(t, "--from", sc.stage, "--yes", "--claude-code") + if code != 0 { + t.Fatalf("exit %d:\n%s", code, out) + } + raw, err := os.ReadFile(filepath.Join(sc.home, ".cortex", "config.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "praxis:") { + t.Errorf("direct-mode setup wrote a praxis block:\n%s", raw) + } +} + +func TestPreflightPorts_PraxisProbesGatewayPorts(t *testing.T) { + fakeSupervisor(t) + env := newTestSetupEnv(t) + env.configFresh = true + freePorts(t) + saved := portInUse + portInUse = func(p string) bool { return p == "47605" } + t.Cleanup(func() { portInUse = saved }) + + if _, prob := (serviceStep{}).preflightPorts(env); prob != nil { + t.Errorf("without --praxis: %+v", prob) + } + env.opts.praxis = true + _, prob := serviceStep{}.preflightPorts(env) + if prob == nil || !strings.Contains(prob.reason, "47605") { + t.Errorf("problem = %+v", prob) + } +} + +// A re-run without --praxis on a praxis config must stage the gateway too. +func TestConfigStep_PraxisConfigNeedsStagedGateway(t *testing.T) { + env, _ := praxisEnv(t, praxisBase+"praxis:\n enabled: true\n") + env.fromDir = t.TempDir() + _, prob := configStep{}.plan(env) + if prob == nil || !strings.Contains(prob.reason, "praxis mode is on") || + !strings.Contains(strings.Join(prob.fix, " "), "sh -s -- --praxis") { + t.Fatalf("problem = %+v", prob) + } + writeExe(t, filepath.Join(env.fromDir, "cortex-gateway"), "#!/bin/sh\n") + if p, prob := (configStep{}).plan(env); prob != nil || !p.done { + t.Errorf("a staged gateway: plan = %+v %v", p, prob) + } +} + +// A praxis: key nested in a plugin's config, before the top-level block, is not the block. +func TestEnablePraxisText_SkipsNestedPraxisKey(t *testing.T) { + src := praxisBase + "pipeline:\n outbound:\n plugins:\n - name: tool-prune\n config:\n praxis: x\npraxis:\n listen: 127.0.0.1:47605\n" + out, changed, err := enablePraxisText(src) + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if !strings.Contains(out, " praxis: x\n") || !loadText(t, out).PraxisEnabled() { + t.Errorf("out =\n%s", out) + } +} + +// A comment after praxis: is not flow style. +func TestEnablePraxisText_CommentOnlyBlock(t *testing.T) { + out, changed, err := enablePraxisText(praxisBase + "praxis: # note\n") + if err != nil || !changed { + t.Fatalf("changed=%v err=%v", changed, err) + } + if !loadText(t, out).PraxisEnabled() { + t.Errorf("out =\n%s", out) + } +} + +// A "---" or "%" directive line is not the document's first key, so it does not +// set the document's indentation. +func TestEnablePraxisText_DocumentMarkers(t *testing.T) { + for _, tc := range []struct{ src, suffix string }{ + {"---\n" + praxisBase, "\npraxis:\n enabled: true\n"}, + {"%YAML 1.1\n---\n mode: proxy-sidecar\n listener:\n roles: [forward]\n bind_loopback_only: true\n", "\n praxis:\n enabled: true\n"}, + {"--- # config\n mode: proxy-sidecar\n listener:\n roles: [forward]\n bind_loopback_only: true\n praxis:\n enabled: false\n", "\n praxis:\n enabled: true\n"}, + } { + out, _, err := enablePraxisText(tc.src) + if err != nil { + t.Fatalf("%q: %v", tc.src, err) + } + if !strings.HasSuffix(out, tc.suffix) { + t.Errorf("out =\n%s", out) + } + if !loadText(t, out).PraxisEnabled() { + t.Errorf("praxis mode is not on:\n%s", out) + } + } +} + +func TestInsertListenerKeys_AfterDocumentMarker(t *testing.T) { + src := "---\n mode: proxy-sidecar\n listener:\n roles: [forward]\n" + out, err := insertListenerKeys(src, []pinnedListener{{key: "forward_proxy_addr", value: `":8081"`}}) + if err != nil { + t.Fatal(err) + } + if want := " roles: [forward]\n forward_proxy_addr: \":8081\"\n"; !strings.HasSuffix(out, want) { + t.Errorf("out =\n%s", out) + } +} + +// The plan says "previous kept", so the edit keeps the file it replaces, one time. +func TestEnablePraxis_KeepsPreviousFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(praxisBase), 0o600); err != nil { + t.Fatal(err) + } + if _, err := enablePraxis(path); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(path + ".before-agentop-migrate"); err != nil || string(got) != praxisBase { + t.Fatalf("backup = %q, err %v; want the file before the edit", got, err) + } + + // An older backup stays: it is the first file setup replaced. + if err := os.WriteFile(path+".before-agentop-migrate", []byte("older\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(praxisBase), 0o600); err != nil { + t.Fatal(err) + } + if _, err := enablePraxis(path); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(path + ".before-agentop-migrate"); string(got) != "older\n" { + t.Errorf("backup = %q, want the older one", got) + } +} diff --git a/cmd/agentop/setup_runner.go b/cmd/agentop/setup_runner.go index a5f15ab66..55d472455 100644 --- a/cmd/agentop/setup_runner.go +++ b/cmd/agentop/setup_runner.go @@ -19,6 +19,7 @@ type setupOptions struct { installOnly bool noModifyPath bool restart bool + praxis bool // add praxis: {enabled: true} to the config handoffBytes int64 handoffSeconds int } @@ -44,7 +45,7 @@ type setupEnv struct { binOnPathNewTerms bool // binDir is, or will be, on PATH in new terminals pathProfile string // the profile the PATH step edits configFresh bool // no config.yaml before this run - configPinsPending bool // the migration adds listener pins, which a running proxy takes only on a restart + configPinsPending bool // the config step adds listener pins or the praxis block, which a running proxy takes only on a restart unsupervised bool // the proxy runs without launchd/systemd priorService bool // a supervised Cortex was serving before this run @@ -54,7 +55,7 @@ type setupEnv struct { priorManual string // what to run if restorePrior fails, which it may set as it fails; "" is agentop service restart onSuccess []func() // run once every step has applied - configPinsChanged bool // the config step added listener pins, so the service must restart + configPinsChanged bool // the config step added listener pins or the praxis block, so the service must restart startedBackground bool // the service step started a background proxy, so the ending says how to stop it } diff --git a/cmd/agentop/setup_step_config.go b/cmd/agentop/setup_step_config.go index d8993c98b..aafa10ba2 100644 --- a/cmd/agentop/setup_step_config.go +++ b/cmd/agentop/setup_step_config.go @@ -22,17 +22,31 @@ func (configStep) name() string { return "config" } func (configStep) plan(env *setupEnv) (stepPlan, *problem) { p := stepPlan{label: "config"} cfg := env.configPath() + if env.opts.praxis { + if prob := praxisGatewayProblem(env); prob != nil { + return p, prob + } + } if !fileExists(cfg) { env.configFresh = true p.verb, p.what, p.where = "write", "the cortex config", env.tilde(cfg) return p, nil } - if _, err := config.Load(cfg); err != nil { + loaded, err := config.Load(cfg) + if err != nil { return p, &problem{reason: env.tilde(cfg) + " will not load: " + err.Error(), fix: []string{"correct it, then " + env.rerun(), "or, to start over from the built-in config, move it aside first: mv " + env.shellPath(cfg) + " " + env.shellPath(cfg+".broken")}} } + // A plain re-run of the installer on a praxis install stages no gateway. The + // new cortex then refuses the old one, and the proxy answers 503. + if !env.opts.praxis && env.fromDir != "" && loaded.PraxisEnabled() { + if prob := praxisGatewayProblem(env); prob != nil { + prob.reason = "praxis mode is on in " + env.tilde(cfg) + ", and " + gatewayBinary + " is not staged" + return p, prob + } + } pending, err := configMigrationPending(cfg) if err != nil { return p, &problem{reason: err.Error()} @@ -47,11 +61,23 @@ func (configStep) plan(env *setupEnv) (stepPlan, *problem) { fix: []string{pinsRemedy}} } } - if !pending { + praxisPending := false + if env.opts.praxis { + raw, err := os.ReadFile(cfg) //nolint:gosec // the user's config + if err != nil { + return p, &problem{reason: err.Error()} + } + if _, praxisPending, err = enablePraxisText(string(raw)); err != nil { + return p, &problem{reason: env.tilde(cfg) + ": " + err.Error(), + fix: []string{praxisHandFix + ", then " + env.rerun()}} + } + } + if !pending && !praxisPending { p.done, p.doneMsg = true, env.tilde(cfg)+" (kept)" return p, nil } - env.configPinsPending = listenerPinsPending(cfg) + // The praxis block, as the pins, takes effect only on a restart. + env.configPinsPending = listenerPinsPending(cfg) || praxisPending p.verb, p.what, p.where = "update", "the cortex config", env.tilde(cfg)+" (previous kept)" return p, nil } @@ -125,9 +151,21 @@ func (configStep) apply(env *setupEnv, _ *checklist.Running) (string, undo, erro // run; Bob's backup is rewritten by each pricing migration. u.manual = manual(".before-agentop-pricing") } - if !env.configFresh && (changed || priced) { + praxisOn := false + if env.opts.praxis { + var perr error + if praxisOn, perr = enablePraxis(cfg); perr != nil { + return "", u, stepError{reason: "praxis mode could not be turned on in " + env.tilde(cfg), + detail: []string{firstLine(perr), praxisHandFix}} + } + env.configPinsChanged = env.configPinsChanged || praxisOn + } + if !env.configFresh && (changed || priced || praxisOn) { detail += " · updated" } + if praxisOn { + detail += " · praxis mode on" + } if pinsErr != nil { detail += " · listener pins not added (" + firstLine(pinsErr) + ")" } diff --git a/cmd/agentop/setup_step_service.go b/cmd/agentop/setup_step_service.go index 84ba163e6..154828d62 100644 --- a/cmd/agentop/setup_step_service.go +++ b/cmd/agentop/setup_step_service.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "time" @@ -100,7 +101,11 @@ type forwardHold struct { func (serviceStep) preflightPorts(env *setupEnv) (forwardHold, *problem) { if env.configFresh { var busy []string - for _, port := range cortexPorts { + ports := cortexPorts + if env.opts.praxis { + ports = slices.Concat(cortexPorts, praxisPorts) + } + for _, port := range ports { if portInUse(port) { busy = append(busy, port) } diff --git a/cmd/cortex/restart_test.go b/cmd/cortex/restart_test.go index 29c635404..ef10ff18a 100644 --- a/cmd/cortex/restart_test.go +++ b/cmd/cortex/restart_test.go @@ -68,8 +68,14 @@ func TestMain(m *testing.M) { } // fakeGateway stands in for cortex-gateway: it listens on the gateway listener of -// its -c file and, with --exit-on-stdin-eof, exits when stdin closes. +// its -c file and, with --exit-on-stdin-eof, exits when stdin closes. It answers +// --version as the real gateway does. func fakeGateway(args []string) { + // The supervisor starts only a gateway of its own version. This copy is the same build. + if slices.Contains(args, "--version") { + fmt.Println(gatewayBinary + " " + version) + os.Exit(0) + } var path string for i, a := range args { if a == "-c" && i+1 < len(args) { diff --git a/cmd/cortex/supervise.go b/cmd/cortex/supervise.go index d25f8c325..fd568b98c 100644 --- a/cmd/cortex/supervise.go +++ b/cmd/cortex/supervise.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "io" @@ -10,6 +11,7 @@ import ( "os/signal" "path/filepath" "slices" + "strings" "sync" "syscall" "time" @@ -175,6 +177,36 @@ type childSpec struct { stdinPipe bool // the supervisor keeps the write end; closing it stops the child fallback *fallbackServer fallbackAddr string // cortex only, with fallback + wantVersion string // when set, the child starts only if `path --version` reports it +} + +// gatewayVersionOf returns the last word of the first line that `path --version` +// prints, or "" on any failure. Tests replace it. +var gatewayVersionOf = func(path string) string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, path, "--version").Output() //nolint:gosec // our own binary + if err != nil { + return "" + } + line, _, _ := strings.Cut(string(out), "\n") + words := strings.Fields(line) + if len(words) == 0 { + return "" + } + return words[len(words)-1] +} + +// gatewayVersionErr returns why a gateway that reports version got must not start, +// or nil when got is want. An empty got means no answer, not a different version. +func gatewayVersionErr(got, want string) error { + switch got { + case want: + return nil + case "": + return fmt.Errorf("cortex-gateway gave no --version answer (it is missing, or older than this cortex), so it does not start") + } + return fmt.Errorf("cortex-gateway version %q differs from cortex %q, so it does not start", got, want) } // supervisorChildren returns the children to run. Direct mode is cortex alone, with @@ -203,11 +235,12 @@ func supervisorChildren(cfg *config.Config, configPath, self string, args []stri } cortex.env = append(os.Environ(), gatewayTokenEnv+"="+tokenPath) gateway := childSpec{ - name: gatewayBinary, - path: filepath.Join(filepath.Dir(self), gatewayBinary), - args: []string{"-c", yamlPath, "--exit-on-stdin-eof"}, - env: []string{}, - stdinPipe: true, + name: gatewayBinary, + path: filepath.Join(filepath.Dir(self), gatewayBinary), + args: []string{"-c", yamlPath, "--exit-on-stdin-eof"}, + env: []string{}, + stdinPipe: true, + wantVersion: version, } return []childSpec{cortex, gateway} } @@ -297,7 +330,13 @@ func superviseChild(spec childSpec, stop <-chan struct{}) error { // closes it however this process exits, SIGKILL included. var stdin io.Closer var err error - if spec.stdinPipe { + if spec.wantVersion != "" { + // A gateway of another version must not start. The backoff retries, so a + // later `agentop update` fixes it without a supervisor restart. The error goes + // to the start-failure log below, which logs ERROR one time only. + err = gatewayVersionErr(gatewayVersionOf(spec.path), spec.wantVersion) + } + if err == nil && spec.stdinPipe { stdin, err = cmd.StdinPipe() } if err == nil { diff --git a/cmd/cortex/supervise_test.go b/cmd/cortex/supervise_test.go index f1e647557..b66304dc9 100644 --- a/cmd/cortex/supervise_test.go +++ b/cmd/cortex/supervise_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "slices" "strconv" + "strings" "testing" "time" @@ -328,3 +329,64 @@ func TestSupervisorChildren_LocalPraxisAddsGateway(t *testing.T) { t.Errorf("gateway token not in ~/.cortex: %v", err) } } + +// The gateway must report the version of the cortex that runs it. Cortex checks no version. +func TestSupervisorChildren_GatewayWantsCortexVersion(t *testing.T) { + specs := supervisorChildren(praxisConfig(), filepath.Join(t.TempDir(), "config.yaml"), "/opt/bin/cortex", nil, writeGatewayFiles) + if len(specs) != 2 { + t.Fatalf("specs = %+v, want cortex and the gateway", specs) + } + if specs[1].wantVersion != version || specs[0].wantVersion != "" { + t.Errorf("wantVersion: cortex %q, gateway %q; want \"\" and %q", specs[0].wantVersion, specs[1].wantVersion, version) + } +} + +// runGatewaySpec runs superviseChild for 200 ms on a script that writes a marker file, +// with gatewayVersionOf replaced by one that returns got. It returns true when the +// script ran. +func runGatewaySpec(t *testing.T, got, want string) bool { + t.Helper() + dir := t.TempDir() + marker := filepath.Join(dir, "started") + script := filepath.Join(dir, "gateway.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\ntouch "+marker+"\nexec cat >/dev/null\n"), 0o700); err != nil { + t.Fatal(err) + } + orig := gatewayVersionOf + gatewayVersionOf = func(string) string { return got } + t.Cleanup(func() { gatewayVersionOf = orig }) + + stop := make(chan struct{}) + time.AfterFunc(200*time.Millisecond, func() { close(stop) }) + spec := childSpec{name: gatewayBinary, path: script, env: []string{}, stdinPipe: true, wantVersion: want} + if err := superviseChild(spec, stop); err != nil { + t.Fatalf("superviseChild: %v", err) + } + _, err := os.Stat(marker) + return err == nil +} + +func TestSuperviseChild_GatewayVersionMismatchDoesNotStart(t *testing.T) { + if runGatewaySpec(t, "v0.0.1", "v9.9.9") { + t.Error("the gateway started, but its version differs from cortex") + } +} + +func TestSuperviseChild_GatewayVersionMatchStarts(t *testing.T) { + if !runGatewaySpec(t, "v9.9.9", "v9.9.9") { + t.Error("the gateway did not start, but its version is the same as cortex") + } +} + +func TestGatewayVersionErr(t *testing.T) { + if err := gatewayVersionErr("v9.9.9", "v9.9.9"); err != nil { + t.Errorf("same version: %v", err) + } + if err := gatewayVersionErr("v0.0.1", "v9.9.9"); err == nil || !strings.Contains(err.Error(), `"v0.0.1" differs from cortex "v9.9.9"`) { + t.Errorf("other version: %v", err) + } + // A missing gateway, or one from before --version, must not read as a version mismatch. + if err := gatewayVersionErr("", "v9.9.9"); err == nil || !strings.Contains(err.Error(), "no --version answer") { + t.Errorf("no answer: %v", err) + } +} diff --git a/core/observe/testdata/config.json b/core/observe/testdata/config.json index 67a272489..1d9984204 100644 --- a/core/observe/testdata/config.json +++ b/core/observe/testdata/config.json @@ -5,5 +5,8 @@ "tls_bridge": { "mode": "enabled", "ca_dir": "/Users/alice/.cortex/ca" + }, + "praxis": { + "enabled": true } } diff --git a/core/observe/wire_fixture_test.go b/core/observe/wire_fixture_test.go index fc636db3a..8c27c3250 100644 --- a/core/observe/wire_fixture_test.go +++ b/core/observe/wire_fixture_test.go @@ -15,6 +15,7 @@ import ( func TestConfigEndpoint_CoversTheWireFixture(t *testing.T) { cfg := newTestConfig() cfg.TLSBridge = &config.TLSBridgeConfig{Mode: "enabled", CADir: "/ca"} + cfg.Praxis = &config.PraxisConfig{Enabled: true} w := httptest.NewRecorder() serveMux(cfg, auth.NewStats()).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config", nil)) wirefixture.Covers(t, "testdata/config.json", w.Body.Bytes()) diff --git a/docs/laptop-service.md b/docs/laptop-service.md index 2e615da82..b3e425940 100644 --- a/docs/laptop-service.md +++ b/docs/laptop-service.md @@ -45,11 +45,32 @@ supervisor reads `praxis.*` only when it starts, so after you change `praxis.*`, `agentop service restart`. Until then, the next restart of the proxy fails at each try. -**On Linux, do not set `praxis.enabled` for the service yet.** The systemd unit does -not use `--supervise`, so nothing starts the gateway or writes `gateway.token`. Then -`cortex` refuses to start (`praxis.enabled needs CORTEX_GATEWAY_TOKEN_FILE …`) and -systemd stops after five tries. A later step (#1722) makes the unit supervise in -praxis mode. +On Linux, the systemd unit runs `cortex --supervise` in praxis mode, so you also see +the supervisor, the proxy and the gateway. In direct mode the unit does not use +`--supervise`, and systemd restarts the proxy itself. `agentop service install` writes +the unit again when it does not agree with `praxis.enabled` in the config file. + +To turn praxis mode on, run the installer with `--praxis`. It installs +`cortex-gateway` and adds `praxis: {enabled: true}` to the config. It changes nothing +else in the file: + +```sh +curl -fsSL https://www.rossoctl.dev/cortex/install.sh | sh -s -- --praxis +``` + +When you change `praxis.enabled` by hand in either direction, run +`agentop service install --restart`. To turn praxis mode off, set +`praxis.enabled: false` in `~/.cortex/config.yaml` first. `restart` alone does not +write the unit again, and on Linux the unit has `--supervise` only in praxis mode. +`install` without `--restart` can report "Already current" and not restart. + +From a source tree, build the gateway and give it to setup: + +```sh +cargo build --release -p cortex-gateway --manifest-path praxis/Cargo.toml +cp praxis/target/release/cortex-gateway bin/ +bin/agentop setup --from bin --yes --no-modify-path --restart --praxis +``` ## Linux: staying up after you log out diff --git a/docs/proxy-contract.md b/docs/proxy-contract.md index ac1b9e205..0c3c4b8c2 100644 --- a/docs/proxy-contract.md +++ b/docs/proxy-contract.md @@ -42,7 +42,7 @@ it, and `agentop pricing` takes `--stats-url`. The pipeline editor and `doctor` | Endpoint | What agentop reads | Reader | |---|---|---| | `GET /reload/status` | `last_success`, `reloads_ok`, `reloads_failed`, `last_error` (shape: `Status` in `core/reloader/status.go`) | `edit.PollUntilReloaded` in `cmd/agentop/edit/status.go`. `localStatsUp` in `cmd/agentop/local_endpoint.go` requires a 2xx with a `reloads_ok` field | -| `GET /config` | `listener.forward_proxy_addr`, `tls_bridge.mode`, `tls_bridge.ca_dir`. It ignores all other fields | `runningConfig` and `runningConfigFields` in `cmd/agentop/local_endpoint.go`, for `agentop exec` | +| `GET /config` | `listener.forward_proxy_addr`, `tls_bridge.mode`, `tls_bridge.ca_dir`, `praxis.enabled`. It ignores all other fields | `runningConfig` and `runningConfigFields` in `cmd/agentop/local_endpoint.go`, for `agentop exec` and `praxisRefusal` | | `GET /pricing/table` | The table (`describeBody`). With `?host=`, the rates for that host (`effective`) | `cmd/agentop/cmd_pricing.go`. A 404 means a proxy older than the endpoint. `Registry.Handler` in `core/cost/pricing/handler.go` serves it | | `GET /tls-bridge/unread` | `tlsbridge.UnreadReport` (`programs`, `hosts`, `osTrustsCA`) | `fetchUnread` in `cmd/agentop/doctor_unread.go`. A non-200 or no route makes the check silent | @@ -193,6 +193,7 @@ flag it sends. | `--no-modify-path` | Edit no shell profile | `--no-modify-path` | | `--yes`, `-y` | Do not ask. Apply the changes setup lists | `--yes` | | `--cpex` | Download `cortex-cpex___.tar.gz`. macOS arm64 and Linux only (`cpex_platform_check`, `require_cpex_build`) | no | +| `--praxis` | Also download `cortex-gateway___.tar.gz`, check it against `checksums.txt` and stage `cortex-gateway`. Setup then turns on praxis mode. macOS arm64 and Linux only (`praxis_platform_check`, `require_praxis_build`). Not frozen: every release that `require_praxis_build` accepts has an agentop that takes `--praxis` | `--praxis` | | `--ref=REF` | Install the binaries of `REF`: `main` (the developer channel) or a release tag. For any other ref, such as a branch or a SHA, the script warns and installs the newest release's binaries (`resolve_version`). A piped run also runs the copy of this script at `REF`. A run from a local file keeps that file | no (removed before the re-run) | | `--stop` | Stop the service and the pidfile proxy, check the ports, exit. Downloads nothing (`stop_cortex`) | no | | `-h`, `--help` | Print the usage text | no | diff --git a/docs/superpowers/plans/2026-11-10-praxis-mode-5-agentop-and-install.md b/docs/superpowers/plans/2026-11-10-praxis-mode-5-agentop-and-install.md new file mode 100644 index 000000000..08080e1a5 --- /dev/null +++ b/docs/superpowers/plans/2026-11-10-praxis-mode-5-agentop-and-install.md @@ -0,0 +1,247 @@ +# Praxis mode 5/8: agentop and the installer — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user turn on praxis mode with `install.sh --praxis`, keep it across `agentop update`, and make agentop refuse the commands that have no effect in praxis mode (#1722). + +**Architecture:** agentop learns `praxis.enabled` from `GET /config` and from the config file. One helper (`praxisRefusal`) refuses `agentop server …`, `agentop script …` and `agentop tools scan --write`. `install.sh --praxis` downloads the `cortex-gateway` archive into the stage and passes `--praxis` to `agentop setup`. Setup then adds `praxis: {enabled: true}` to the config with a targeted line edit. The systemd unit uses `--supervise` in praxis mode. The supervisor refuses a gateway whose `--version` differs from its own. + +**Tech Stack:** Go 1.26.10 (`cmd/agentop`, `cmd/cortex`, `core/observe`), POSIX sh (`scripts/install.sh`), Rust (`praxis/server`, Praxis 0.8.0). + +**Spec:** `docs/superpowers/specs/2026-11-07-praxis-mode-design.md` (Section 4: "Config", "Supervision", "agentop", "Release and install"; Section 6 step 5). Issue: #1722. Earlier plans: `2026-11-09-praxis-mode-3-supervise-gateway.md` (decisions 17 and 20 defer work to this step), `2026-11-09-praxis-mode-4-hop-and-egress.md`. + +## Global Constraints + +- Direct mode does not change. With `praxis.enabled` false or absent, every existing test passes with no change to its expectations. +- `make dev-install` and `agentop setup` work without `cortex-gateway` while `praxis.enabled` is false. +- Setup with `--praxis` adds the `praxis` block, new config or existing, and changes nothing else in the file. Plugins and `agentop server` entries stay. Setup without `--praxis` leaves an existing `praxis` block as it is. +- Turning the mode off is `praxis.enabled: false` and a restart. No code path removes the block. +- `install.sh --praxis` refuses darwin/amd64. Supported: darwin/arm64, linux/amd64, linux/arm64. +- Archive name, verbatim: `cortex-gateway___.tar.gz`. It holds one file, `cortex-gateway`. +- Gateway version line, verbatim: `cortex-gateway `. `` is the build-time env `CORTEX_VERSION`, or `dev` when it is not set. +- Ports in praxis mode: `47605` (praxis.listen) and `47606` (praxis.egress). +- Run per-module Go commands with `GOWORK=off`. Log with `log/slog`. +- Comments and commit messages in ASD-STE100 style. Commits use `git commit -s` and end with `Assisted-By: Claude (Anthropic AI) `. No `Co-Authored-By`. +- Prose says Cortex for the product (CLAUDE.md naming rule). + +## Decisions (made for this plan, with reasons) + +1. **Setup learns praxis mode from a new `--praxis` flag.** `install.sh` passes it only when the user gave `--praxis`. Reason: the `hand_off` flags are frozen because `--ref=main` can run a newer script against an older agentop. `install.sh --praxis` refuses every release that has no gateway archive, and the first release with one also has an agentop with `--praxis`. So an older agentop never receives the flag. An env var would be silently ignored by an old agentop, which is worse than a usage error. +2. **`praxisRefusal` refuses when the config file sets `praxis.enabled`, OR the running Cortex reports it in `GET /config`.** Reason: the file is what the next start runs, and `/config` is what runs now. In both cases a plugin edit has no effect until the mode is off and Cortex restarts. The message names both facts. +3. **The check sits in `serverTarget`.** Reason: every `agentop server` action and every `agentop script` action loads its target there, so one call covers all of them. Help output (`agentop server help`, `agentop script help`) does not call `serverTarget` and still works. `agentop tools scan` without `--write` only reads transcripts, so it still works. +4. **Refusal message, verbatim:** `agentop: praxis mode is on (), so Cortex runs no plugins and this command has no effect. To turn it off, set praxis.enabled: false in and run: agentop service restart`. `` is `in `, `in the running Cortex`, or `in and in the running Cortex`. Exit status 1. +5. **Setup adds the block with a line edit, not a YAML round trip.** Three cases: no top-level `praxis:` key: append `praxis:\n enabled: true\n` at the end, with the indentation of the file's first top-level key. `praxis:` block without `enabled:`: insert `enabled: true` into the block. `enabled:` with any other value: replace the value with `true` on that line. A flow-style `praxis: {…}` is a problem with the fix "write `enabled: true` under praxis: by hand". Reason: the file keeps its comments and order, as `insertListenerKeys` does. The result goes through `replaceConfig` with a verify that requires `cfg.PraxisEnabled()` and `config.Validate(cfg) == nil`, so a config that praxis mode refuses (no `bind_loopback_only`, `mtls` set) never replaces the original. +6. **`insertListenerKeys` becomes `insertBlockKeys(src, block string, pins []pinnedListener)`.** `insertListenerKeys(src, pins)` stays as a one-line wrapper. Reason: the praxis insert needs the same indentation logic. No second copy. +7. **Setup with `--praxis` needs `cortex-gateway`:** staged in `--from`, or else installed in the bin dir when there is no `--from`. Otherwise the config step plans a problem: `cortex-gateway is not installed; install it with: curl -fsSL https://www.rossoctl.dev/cortex/install.sh | sh -s -- --praxis`. Reason: a praxis config without the gateway gives a proxy that answers 503 to every request. +8. **The systemd unit adds `--supervise` when the config file sets `praxis.enabled`.** `servicePaths` gets `supervise bool`. `runServiceInstall` sets it from the config file right before it writes the unit. `serviceIsCurrent` returns false on Linux when the unit's `--supervise` does not match the config. Reason: plan 3 decision 20. Direct mode keeps today's unit (Restart=on-failure and StartLimit). Setup's config step runs before the service step, so the unit sees the new block. +9. **The supervisor checks the gateway version before each gateway start.** `childSpec` gets `wantVersion string`, set to `version` for the gateway. Before each start, `superviseChild` runs ` --version` with a 2 s timeout. When the last word differs from `wantVersion`, it logs `supervisor: cortex-gateway version differs from cortex, so it does not start` with both versions, and treats it as a failed start (normal backoff). Reason: a mismatch must not start, and a backoff retry lets a later `agentop update` fix it with no restart of the supervisor. A missing binary already fails each start in the same way. The check is a package var `gatewayVersionOf func(path string) string` so tests replace it. +10. **`cortex-gateway --version` prints `cortex-gateway ` and exits 0 before any config load.** `` is `option_env!("CORTEX_VERSION").unwrap_or("dev")`. Reason: the supervisor needs a version, and a dev build of both binaries reads `dev`, so `make dev-install` plus a local `cargo build` still matches. The release job (step 7) sets `CORTEX_VERSION`. +11. **`install.sh` downloads the gateway archive after the two main archives, with a plain `curl`, not through the progress bar.** It is checked against `checksums.txt` with the same anchored match, and the expected count of checksum lines becomes 3. Reason: the bar code tracks exactly two pids. The gateway archive is small next to `cortex`. Add the comment `shortcut: the bar does not count the gateway archive, add it to the bar if users report a stall`. +12. **`require_praxis_build ` mirrors `require_cpex_build`** and runs in both places that `require_cpex_build` runs (before the re-exec for a `v*` ref, and after `resolve_version`). Message: `release has no cortex-gateway build for /`. `praxis_platform_check` mirrors `cpex_platform_check`. `--praxis` with `AUTHBRIDGE_SKIP_DOWNLOAD=1` dies as `--cpex` does. Reason: no release has the archive before step 7, so every current release is refused with a clear message (spec, Section 6 step 7). +13. **`agentop update` passes `--praxis` when `~/.cortex/config.yaml` loads and sets `praxis.enabled`.** A config that does not load passes nothing. Reason: the spec says update detects the mode from the config, as it detects `--cpex` from the binary. +14. **The port preflight of a fresh install also probes `47605` and `47606` when `opts.praxis` is set.** Reason: plan 3 decision 17. A fresh install has no config, so the flag is the only signal. +15. **No new Makefile target for the gateway.** A developer who wants praxis mode from a tree runs `cargo build --release -p cortex-gateway --manifest-path praxis/Cargo.toml`, copies `praxis/target/release/cortex-gateway` into `bin/`, and runs `bin/agentop setup --from bin --yes --no-modify-path --restart --praxis`. `docs/laptop-service.md` says so. Reason: YAGNI. `make dev-install` must work with no Rust toolchain. +16. **The plugin pane needs no change.** Reason: in praxis mode Cortex builds no outbound pipeline (plan 4), so `GET /v1/pipeline` already reports no plugins. +17. **`install.sh --stop` and `agentop uninstall` need no change.** Reason: plan 3 already added `cortex-gateway` to both. +18. **One PR.** The estimate is about 1,250 counted lines, under the 2,000 limit. + +## Review Focus + +1. A user runs `install.sh --praxis` over an existing config with plugins and `agentop server` entries: only the `praxis` block may change. Pinned in Task 4 (`TestConfigStep_PraxisKeepsEverythingElse`). +2. A config whose `praxis:` block says `enabled: false` with a comment after the value: the edit must set `true` and keep the comment. Pinned in Task 4 (`TestEnablePraxis_ReplacesFalseKeepsComment`). +3. Setup with `--praxis` on a config without `listener.bind_loopback_only` that the pin migration cannot fix: setup must refuse and leave the file. Pinned in Task 4 (`TestEnablePraxis_InvalidPraxisConfigLeavesFile`). +4. A user turns praxis off in the file, but Cortex still runs in praxis mode: `agentop server add` must refuse and name the running Cortex. Pinned in Task 2 (`TestPraxisRefusal_RunningOnly`). +5. `agentop update` on a praxis install whose config does not load: update must not crash and must not pass `--praxis`. Pinned in Task 6 (`TestUpdateFlags_PraxisConfigBrokenPassesNothing`). + +--- + +## File map + +| File | Task | Change | +|---|---|---| +| `cmd/agentop/local_endpoint.go`, `core/observe/testdata/config.json`, `core/observe/wire_fixture_test.go`, `cmd/agentop/local_endpoint_test.go`, `docs/proxy-contract.md` (`GET /config` row) | 1 | decode `praxis.enabled` | +| `cmd/agentop/praxis_mode.go` (new), `cmd/agentop/praxis_mode_test.go` (new), `cmd/agentop/cmd_server.go`, `cmd/agentop/cmd_tools.go` | 2 | the refusal helper and its callers | +| `praxis/server/src/main.rs`, `cmd/cortex/supervise.go`, `cmd/cortex/supervise_test.go` | 3 | gateway `--version`, supervisor version check | +| `cmd/agentop/cmd_setup.go`, `cmd/agentop/setup_runner.go`, `cmd/agentop/setup_step_config.go`, `cmd/agentop/cmd_config_migrate.go`, `cmd/agentop/setup_step_service.go`, `cmd/agentop/setup_praxis.go` (new), `cmd/agentop/setup_praxis_test.go` (new) | 4 | `setup --praxis` | +| `cmd/agentop/cmd_service.go`, `cmd/agentop/cmd_service_platform.go`, `cmd/agentop/cmd_service_test.go`, `docs/laptop-service.md` | 5 | systemd `--supervise` in praxis mode, docs | +| `cmd/agentop/cmd_update.go`, `cmd/agentop/cmd_update_test.go` | 6 | update keeps `--praxis` | +| `scripts/install.sh`, `scripts/install_test.sh`, `docs/proxy-contract.md` (`install.sh` flags table) | 7 | `install.sh --praxis` | + +## PRs + +| PR | Title | Parent | Tasks | estLines | +|---|---|---|---|---| +| P1 | `feat: Support praxis mode in agentop and the installer` | "" | T1, T2, T3, T4, T5, T6, T7 | 1250 | + +--- + +### Task 1: Decode `praxis.enabled` from `GET /config` + +Depends on: none. Estimate: 70 lines. + +**Files:** +- Modify: `cmd/agentop/local_endpoint.go` (`runningConfigFields`, `runningConfig`) +- Modify: `core/observe/testdata/config.json`, `core/observe/wire_fixture_test.go` +- Test: `cmd/agentop/local_endpoint_test.go` +- Modify: `docs/proxy-contract.md` (the `GET /config` row: four fields, and `praxisRefusal` as a second reader) + +**Interfaces:** +- Produces: `runningConfig(base string) (*config.Config, error)` now sets `cfg.Praxis = &config.PraxisConfig{Enabled: true}` when `/config` has `"praxis": {"enabled": true}`. `cfg.Praxis` stays nil otherwise. + +- [ ] **Step 1: Write the failing tests.** `TestRunningConfig_ReadsPraxisEnabled`: a test server answers `{"listener":{"forward_proxy_addr":"127.0.0.1:47600"},"praxis":{"enabled":true}}`. Assert `cfg.PraxisEnabled()` is true. `TestRunningConfig_NoPraxisBlock`: the same body without `praxis`. Assert `cfg.Praxis == nil`. +- [ ] **Step 2: Run** `GOWORK=off go -C cmd/agentop test -run 'TestRunningConfig_' .` Expected: FAIL. +- [ ] **Step 3: Implement.** Add `Praxis *struct{ Enabled bool \`json:"enabled"\` } \`json:"praxis"\`` to `runningConfigFields` and copy it into `cfg`. Add `"praxis": {"enabled": true}` to the fixture. In `TestConfigEndpoint_CoversTheWireFixture`, set `cfg.Praxis = &config.PraxisConfig{Enabled: true}`. Update the doc row. +- [ ] **Step 4: Run** `GOWORK=off go -C cmd/agentop test -run 'TestRunningConfig_|TestStatsDecoders' . && GOWORK=off go -C core test ./observe/...` Expected: PASS. +- [ ] **Step 5: Commit** `feat: Read praxis.enabled from the running Cortex config`. + +### Task 2: One helper refuses the commands that praxis mode makes useless + +Depends on: T1. Estimate: 170 lines. + +**Files:** +- Create: `cmd/agentop/praxis_mode.go`, `cmd/agentop/praxis_mode_test.go` +- Modify: `cmd/agentop/cmd_server.go` (`serverTarget`), `cmd/agentop/cmd_tools.go` (before `toolscan.PatchConfig`) + +**Interfaces:** +- Consumes: `runningConfig` (T1). +- Produces: `func praxisRefusal(cfg *config.Config, path, statsURL string) error`. It returns nil when neither source sets the mode. `statsURL == ""` skips the `/config` read. A failed `/config` read counts as "not set". The error text is decision 4, without the `agentop: ` prefix (callers print it with their usual prefix). + +- [ ] **Step 1: Write the failing tests** in `praxis_mode_test.go`: + - `TestPraxisRefusal_DirectMode`: file without praxis, server without praxis. Assert nil. + - `TestPraxisRefusal_FileOnly`: `cfg.Praxis = &config.PraxisConfig{Enabled: true}`, `statsURL ""`. Assert the error contains `praxis mode is on (in )` and `agentop service restart`. + - `TestPraxisRefusal_RunningOnly`: file direct mode, test server reports `praxis.enabled: true`. Assert the error contains `in the running Cortex`. + - `TestServerAdd_RefusedInPraxisMode`: a temp config with `praxis: {enabled: true}` and `listener.bind_loopback_only: true`. Run `runServerCmd(t, "", "add", "ete", "https://ete.example.com", "--main", "opus", "--helper", "haiku", "--yes", "--config", path)`. Assert exit 1, the refusal on stderr, and the file bytes unchanged. + - `TestScriptList_RefusedInPraxisMode`: the same config, `runScriptCmd(t, "list", "--config", path)`. Assert exit 1. + - `TestToolsScanWrite_RefusedInPraxisMode`: `runTools` with `scan --dir --write `. Assert exit 1 and file unchanged. `TestToolsScan_NoWriteWorksInPraxisMode`: no `--write`. Assert exit 0. +- [ ] **Step 2: Run** `GOWORK=off go -C cmd/agentop test -run 'Praxis' .` Expected: FAIL. +- [ ] **Step 3: Implement** `praxisRefusal`. Call it at the end of `serverTarget` (return its error). In `runTools`, when `*write != ""`, load the file with `config.Load`, find the stats URL as `serverTarget` does (`dialURL(cfg.Stats.StatsAddress)` plus `localStatsUp`), and call `praxisRefusal` before the scan. A file that does not load keeps today's path (PatchConfig reports the error). +- [ ] **Step 4: Run** `scripts/dev/go-test-shards.sh -short cmd/agentop` Expected: PASS. +- [ ] **Step 5: Commit** `feat: Refuse plugin edits in praxis mode`. + +### Task 3: The supervisor refuses a gateway of another version + +Depends on: none. Estimate: 140 lines. + +**Files:** +- Modify: `praxis/server/src/main.rs` +- Modify: `cmd/cortex/supervise.go` (`childSpec`, `supervisorChildren`, `superviseChild`) +- Test: `cmd/cortex/supervise_test.go`, unit test in `praxis/server/src/main.rs` + +**Interfaces:** +- Produces: `cortex-gateway --version` prints `cortex-gateway \n`, exit 0. Go: `var gatewayVersionOf = func(path string) string` (last word of the first line of `path --version`, "" on any failure, 2 s timeout, stdin nil). `childSpec.wantVersion string`. + +- [ ] **Step 1: Write the failing tests.** + - Rust: `fn version_line_names_the_binary()` asserts `version_line()` starts with `"cortex-gateway "` and its last word is `option_env!("CORTEX_VERSION").unwrap_or("dev")`. + - Go: `TestSupervisorChildren_GatewayWantsCortexVersion`: in `TestSupervisorChildren_PraxisAddsGateway`'s setup, assert `specs[1].wantVersion == version` and `specs[0].wantVersion == ""`. + - Go: `TestSuperviseChild_GatewayVersionMismatchDoesNotStart`: set `gatewayVersionOf` to return `"v0.0.1"`, `wantVersion "v9.9.9"`, path a script that writes a marker file. Close `stop` after 200 ms. Assert the marker file does not exist. + - Go: `TestSuperviseChild_GatewayVersionMatchStarts`: `gatewayVersionOf` returns `wantVersion`. Assert the marker file exists. +- [ ] **Step 2: Run** `cargo test --manifest-path praxis/Cargo.toml -p cortex-gateway` and `GOWORK=off go -C cmd/cortex test -tags "$(GOWORK=off go -C scripts/profile-tags run . local)" -run 'TestSupervis' .` Expected: FAIL. +- [ ] **Step 3: Implement.** Rust: `fn version_line() -> String`. In `main`, before `install_crypto_provider`, when any arg is `--version`, print `version_line()` and return. Go: set `wantVersion: version` on the gateway spec. In `superviseChild`, before `exec.Command`, when `spec.wantVersion != ""` and `gatewayVersionOf(spec.path) != spec.wantVersion`, log per decision 9 (`"want", spec.wantVersion, "got", got`) and set `err` to `fmt.Errorf("cortex-gateway version %q differs from cortex %q", got, spec.wantVersion)` before the `if err == nil { err = cmd.Start() }` line, so the existing start-error branch (backoff, retry) handles it. Do not call `cmd.Start()` when `err` is set. +- [ ] **Step 4: Run** the Step 2 commands. Expected: PASS. Also `cargo clippy --manifest-path praxis/Cargo.toml --all-targets --locked -- -D warnings`. +- [ ] **Step 5: Commit** `feat: Refuse a cortex-gateway whose version differs from cortex`. + +### Task 4: `agentop setup --praxis` + +Depends on: none. Estimate: 420 lines. + +**Files:** +- Modify: `cmd/agentop/setup_runner.go` (`setupOptions.praxis bool`), `cmd/agentop/cmd_setup.go` (`fs.BoolVar(&o.praxis, "praxis", false, "")`, `setupUsage` line `--praxis turn on praxis mode (needs cortex-gateway)`) +- Modify: `cmd/agentop/cmd_config_migrate.go` (decision 6) +- Create: `cmd/agentop/setup_praxis.go`, `cmd/agentop/setup_praxis_test.go` +- Modify: `cmd/agentop/setup_step_config.go` (plan and apply), `cmd/agentop/setup_step_service.go` (`preflightPorts`) + +**Interfaces:** +- Produces: + - `func insertBlockKeys(src, block string, pins []pinnedListener) (string, error)`. + - `func enablePraxisText(src string) (out string, changed bool, err error)` (decision 5, pure text). + - `func enablePraxis(path string) (changed bool, err error)`: reads, calls `enablePraxisText`, and writes through `replaceConfig` with the decision-5 verify. `changed` false when the file already sets `enabled: true`. + - `func praxisGatewayProblem(env *setupEnv) *problem` (decision 7). + - `var praxisPorts = []string{"47605", "47606"}`. + +- [ ] **Step 1: Write the failing tests** in `setup_praxis_test.go`: + - `TestEnablePraxisText_AppendsBlock`: input without `praxis:`. Assert the output is the input plus `praxis:\n enabled: true\n`, and loads with `PraxisEnabled()`. + - `TestEnablePraxisText_IndentedDocument`: every top-level key indented 2 spaces. Assert the appended `praxis:` has 2 spaces and `enabled:` has 4. + - `TestEnablePraxisText_InsertsEnabled`: `praxis:\n listen: 127.0.0.1:47605\n`. Assert `enabled: true` lands inside the block and `listen` stays. + - `TestEnablePraxis_ReplacesFalseKeepsComment`: `praxis:\n enabled: false # off for now\n`. Assert the line becomes ` enabled: true # off for now`. + - `TestEnablePraxisText_AlreadyOn`: assert `changed == false` and the output equals the input. + - `TestEnablePraxisText_FlowStyleRefused`: `praxis: {enabled: false}`. Assert an error. + - `TestEnablePraxis_InvalidPraxisConfigLeavesFile`: a config without `bind_loopback_only`. Assert an error and file bytes unchanged. + - `TestConfigStep_PraxisKeepsEverythingElse`: an existing config with two outbound plugins and a router `servers:` entry. Run the config step with `opts.praxis`. Assert the only diff lines are the added `praxis` lines. + - `TestConfigStep_NoPraxisFlagLeavesBlock`: an existing config with `praxis: {enabled: true}` written as a block, no flag. Assert the file is unchanged. + - `TestConfigStep_PraxisNeedsGateway`: `opts.praxis`, a `--from` dir without `cortex-gateway`. Assert the plan returns the decision-7 problem. + - `TestSetup_NoGatewayDirectModeWorks`: a copy of `TestSetupFreshInstallEndToEnd` (`setup_e2e_test.go`) with no `cortex-gateway` in the `--from` dir or the bin dir, and no `--praxis`. Assert success and that the written config has no `praxis:` key (pins the Global Constraint). + - `TestPreflightPorts_PraxisProbesGatewayPorts`: fresh install, `opts.praxis`, `portInUse = func(p string) bool { return p == "47605" }` (the existing seam in `setup_probe.go`). Assert the problem names 47605. Without `opts.praxis`, assert no problem. +- [ ] **Step 2: Run** `GOWORK=off go -C cmd/agentop test -run 'Praxis|NoGateway' .` Expected: FAIL. +- [ ] **Step 3: Implement.** In `configStep.plan`: when `opts.praxis`, return `praxisGatewayProblem(env)` if it is not nil. For an existing config, read the file and call `enablePraxisText`. When `changed` is true, the plan is `update` (not `done`). When the call returns an error (flow style), return a problem with that error and decision 5's fix. In `configStep.apply`: after the pin migration and the Bob pricing, call `enablePraxis(cfg)` when `opts.praxis`. A failure is a `stepError` (the step's undo restores the snapshot). Append ` · praxis mode on` to the detail when it changed. In `preflightPorts`: probe `append(cortexPorts, praxisPorts...)` when `opts.praxis`. +- [ ] **Step 4: Run** `scripts/dev/go-test-shards.sh -short cmd/agentop`, then `scripts/dev/go-test-shards.sh -race cmd/agentop` (setup scenes). Expected: PASS. +- [ ] **Step 5: Commit** `feat: Add the praxis block from agentop setup --praxis`. + +### Task 5: The systemd unit supervises in praxis mode + +Depends on: none. Estimate: 130 lines. + +**Files:** +- Modify: `cmd/agentop/cmd_service.go` (`servicePaths.supervise`, `runServiceInstall`, `serviceIsCurrent`) +- Modify: `cmd/agentop/cmd_service_platform.go` (`renderUnitFor` Linux branch, and its comment) +- Test: `cmd/agentop/cmd_service_test.go` +- Modify: `docs/laptop-service.md` (remove the "On Linux, do not set `praxis.enabled`" paragraph; add how to turn praxis mode on with `install.sh --praxis` and off with `praxis.enabled: false` plus `agentop service restart`; add decision 15's developer steps) + +**Interfaces:** +- Produces: `func configPraxisEnabled(path string) bool` in `cmd_service.go` (false when the file does not load). T6 uses it. + +- [ ] **Step 1: Write the failing tests.** `TestRenderUnit_LinuxPraxisSupervises`: `p.supervise = true`. Assert `ExecStart=` contains `--supervise`. `TestRenderUnit_LinuxDirectUnchanged`: `p.supervise = false`. Assert the unit equals today's text. `TestConfigPraxisEnabled`: three files (praxis on, praxis off, broken YAML). Assert true, false, false. +- [ ] **Step 2: Run** `GOWORK=off go -C cmd/agentop test -run 'TestRenderUnit_Linux|TestConfigPraxisEnabled' .` Expected: FAIL. +- [ ] **Step 3: Implement.** Linux `ExecStart` adds ` --supervise` after the binary when `p.supervise`. `runServiceInstall` sets `p.supervise = configPraxisEnabled(p.configFile)` before `renderUnit`. `serviceIsCurrent` on Linux returns false when `strings.Contains(body, "--supervise") != configPraxisEnabled(p.configFile)`. Update the comment above `renderUnitFor` that says the unit never uses `--supervise`. +- [ ] **Step 4: Run** `scripts/dev/go-test-shards.sh -race cmd/agentop`. Expected: PASS. +- [ ] **Step 5: Commit** `feat: Supervise the gateway under systemd in praxis mode`. + +### Task 6: `agentop update` keeps praxis mode + +Depends on: T5. Estimate: 50 lines. + +**Files:** +- Modify: `cmd/agentop/cmd_update.go` (`updateFlags`, and the help text line that lists what update keeps) +- Test: `cmd/agentop/cmd_update_test.go` + +**Interfaces:** +- Consumes: `configPraxisEnabled(path string) bool` (T5). + +- [ ] **Step 1: Write the failing tests.** `TestUpdateFlags_PraxisConfigPassesPraxis`: `env.configPath()` sets praxis on. Assert `--praxis` in the flags. `TestUpdateFlags_DirectConfigNoPraxis`: assert no `--praxis`. `TestUpdateFlags_PraxisConfigBrokenPassesNothing`: unparseable YAML. Assert no `--praxis` and no panic. +- [ ] **Step 2: Run** `GOWORK=off go -C cmd/agentop test -run 'TestUpdateFlags_' .` Expected: FAIL. +- [ ] **Step 3: Implement.** After the `--cpex` line: `if configPraxisEnabled(env.configPath()) { flags = append(flags, "--praxis") }`. Add "praxis mode" to the help text list beside "the cpex build". +- [ ] **Step 4: Run** the Step 2 command. Expected: PASS. +- [ ] **Step 5: Commit** `feat: Keep praxis mode across agentop update`. + +### Task 7: `install.sh --praxis` + +Depends on: T1 (both tasks edit `docs/proxy-contract.md`; run T7 after T1 so the two edits do not conflict). Estimate: 270 lines. + +**Files:** +- Modify: `scripts/install.sh` (arg loop, `usage`, unknown-option message, `praxis_platform_check`, `require_praxis_build`, the re-exec check, the skip-download check, the download, checksum and extract steps, `hand_off`) +- Test: `scripts/install_test.sh` +- Modify: `docs/proxy-contract.md` (`install.sh` flags table: a `--praxis` row, passed to setup as `--praxis`) + +**Interfaces:** +- Consumes: setup's `--praxis` flag (T4) by name only. The test uses a fake agentop, so no code dependency. +- Produces: the stage holds `cortex-gateway` (mode 0755, quarantine cleared on darwin) when `--praxis` is set. `hand_off` appends `--praxis`. + +- [ ] **Step 1: Write the failing tests** in `install_test.sh`, in the style of the `--cpex` block (`make_release`, `run_install`, `check`): + - `--praxis` on darwin/amd64 dies with `--praxis is not available for darwin/amd64 (supported: darwin/arm64, linux/amd64, linux/arm64)`. + - `--praxis` from a release without a `cortex-gateway_…` line dies with `release v9.9.9 has no cortex-gateway build for linux/amd64`. + - `--praxis` from a release with the archive: the fake agentop receives `--praxis`, and the stage has an executable `cortex-gateway`. + - `--praxis` with a gateway archive whose checksum does not match: dies with `checksum verification failed`. + - No `--praxis`: the fake agentop does not receive `--praxis`, and the gateway archive is not fetched. + - `--praxis` with `AUTHBRIDGE_SKIP_DOWNLOAD=1` dies. + - Extend `make_release` with a `praxis` and a `nopraxis` mode, as `cpex`/`nocpex`. +- [ ] **Step 2: Run** `sh scripts/install_test.sh`. Expected: the new checks FAIL. +- [ ] **Step 3: Implement** decisions 11 and 12. Usage line: `--praxis also install cortex-gateway and turn on praxis mode (macOS arm64, Linux amd64/arm64). To turn it off, set praxis.enabled: false in ~/.cortex/config.yaml and run agentop service restart`. Put `--praxis` in the unknown-option message list. Add the gateway archive to the checksum loop only when `PRAXIS` is set, and make the line count `2` or `3` to match. Add its bytes to `bytes`. Extract it, require `cortex-gateway` in the stage, `chmod +x`, and clear quarantine. +- [ ] **Step 4: Run** `sh scripts/install_test.sh` and `shellcheck scripts/install.sh` when `shellcheck` is installed. Expected: PASS. +- [ ] **Step 5: Commit** `feat: Add install.sh --praxis`. + +--- + +## Self-review + +- Spec coverage (Section 4, "agentop" and "Release and install"): `/config` field → T1. Refusals with one helper → T2. `install.sh --praxis`, darwin/amd64 refusal, gateway build missing → T7. Setup adds the block, keeps the rest, needs the gateway → T4. Turn off = `enabled: false` plus restart → T2 message, T5 docs, no code removes the block. `agentop update` keeps it → T6. Supervisor version refusal → T3. `cortexPorts` 47605/47606 (plan 3 decision 17) → T4. systemd `--supervise` (plan 3 decision 20) → T5. Plugin pane empty, `--stop`, uninstall → decisions 16 and 17, no task. +- Out of scope, by delivery step: MCP and A2A parsers (step 6). The release job that publishes `cortex-gateway_*` archives and sets `CORTEX_VERSION` (step 7). Until step 7, `install.sh --praxis` refuses every release. That is intended (spec Section 6, step 7). +- Issue "Done when", first item ("a fresh `install.sh --praxis` gives a working praxis mode"): only testable by hand after step 7 publishes an archive. Before that, the PR body describes a manual run from a tree with decision 15's steps. diff --git a/praxis/server/src/main.rs b/praxis/server/src/main.rs index a71c178ca..32e114385 100644 --- a/praxis/server/src/main.rs +++ b/praxis/server/src/main.rs @@ -15,6 +15,11 @@ praxis_filter::register_filters! { const EXIT_ON_STDIN_EOF: &str = "--exit-on-stdin-eof"; fn main() { + // The supervisor reads this before each start. No config is loaded. + if has_flag(std::env::args().skip(1), "--version") { + println!("{}", version_line()); + return; + } // Before anything that can build a TLS config. praxis::install_crypto_provider(); @@ -39,6 +44,15 @@ fn main() { praxis::run_server_with_registry(config, custom_registry(), config_path, log_level) } +/// `cortex-gateway `. The release build sets `CORTEX_VERSION` to the +/// cortex version. A local build reads `dev`, as a local cortex build does. +fn version_line() -> String { + format!( + "cortex-gateway {}", + option_env!("CORTEX_VERSION").unwrap_or("dev") + ) +} + /// The value of `-c ` or `--config `. fn config_arg(mut args: impl Iterator) -> Option { while let Some(arg) = args.next() { @@ -56,7 +70,7 @@ fn has_flag(mut args: impl Iterator, flag: &str) -> bool { #[cfg(test)] mod tests { - use super::{EXIT_ON_STDIN_EOF, config_arg, has_flag}; + use super::{EXIT_ON_STDIN_EOF, config_arg, has_flag, version_line}; fn args(list: &[&str]) -> impl Iterator { list.iter() @@ -89,4 +103,13 @@ mod tests { } assert!(!has_flag(args(&["-c", "a.yaml"]), EXIT_ON_STDIN_EOF)); } + #[test] + fn version_line_names_the_binary() { + let line = version_line(); + assert!(line.starts_with("cortex-gateway "), "{line}"); + assert_eq!( + line.split_whitespace().last(), + Some(option_env!("CORTEX_VERSION").unwrap_or("dev")) + ); + } } diff --git a/scripts/install.sh b/scripts/install.sh index 4e010d01b..03ef9a29e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -180,6 +180,10 @@ Options: --yes, -y do not ask; apply the changes setup lists --cpex install cortex built with the cpex policy plugin (macOS arm64, Linux amd64/arm64). Re-run without it to switch back + --praxis also install cortex-gateway and turn on praxis mode (macOS + arm64, Linux amd64/arm64). To turn it off, set + praxis.enabled: false in ~/.cortex/config.yaml and run + agentop service install --restart --ref=REF install from a git ref instead of the newest stable release — both this script and the binaries (e.g. --ref=main for unreleased changes, --ref=v0.8.1 to pin). The only way to @@ -216,10 +220,14 @@ NO_MODIFY_PATH="" # Setup is not told: to it, this is one more cortex binary. Released copies of this # script from before the flag reject it as an unknown option. CPEX="" +# PRAXIS also downloads the cortex-gateway archive and passes --praxis to setup, which +# adds `praxis: {enabled: true}` to the config. +PRAXIS="" for arg in "$@"; do case "$arg" in --install-only) MODE=install-only ;; --cpex) CPEX=1 ;; + --praxis) PRAXIS=1 ;; --claude-code) WIRE_CLAUDE_CODE=1 ;; --yes | -y) ASSUME_YES=1 ;; --ref=*) WANT_REF="${arg#*=}" ;; @@ -233,7 +241,7 @@ for arg in "$@"; do usage exit 0 ;; - *) die "unknown option: $arg (try --claude-code, --cpex, --install-only, --local, --no-modify-path, --no-service, --stop, --ref=REF, --yes, or no argument)" ;; + *) die "unknown option: $arg (try --claude-code, --cpex, --install-only, --local, --no-modify-path, --no-service, --praxis, --stop, --ref=REF, --yes, or no argument)" ;; esac done # Removed knobs die rather than being ignored. Left set in someone's shell, @@ -425,6 +433,24 @@ require_cpex_build() { # version || die "release $1 has no cpex build for ${os}/${arch}" } +# praxis_platform_check dies unless the release builds cortex-gateway for os/arch. +praxis_platform_check() { + case "${os}/${arch}" in + darwin/arm64 | linux/amd64 | linux/arm64) ;; + *) die "--praxis is not available for ${os}/${arch} (supported: darwin/arm64, linux/amd64, linux/arm64)" ;; + esac +} + +# require_praxis_build dies unless release $1's checksums.txt lists the cortex-gateway +# archive for os/arch, as require_cpex_build does for the cpex archive. +require_praxis_build() { # version + _rp_sums=$(curl -fsSL "https://github.com/${REPO}/releases/download/$1/checksums.txt" 2>/dev/null) \ + || die "could not fetch checksums.txt for $1" + _rp_re=$(ere_escape "cortex-gateway_$1_${os}_${arch}.tar.gz") + printf '%s\n' "${_rp_sums}" | grep -qE "^[0-9a-fA-F]+[[:space:]]+\*?(\./)?${_rp_re}\$" \ + || die "release $1 has no cortex-gateway build for ${os}/${arch}" +} + # --- run the released copy of this script, not the one from main --- # # The documented command fetches this file from main, which is whatever landed @@ -500,6 +526,16 @@ if [ -n "${_reexec}" ]; then ;; esac fi + # --praxis, for the same reason. + if [ -n "${PRAXIS:-}" ]; then + case "${want_ref}" in + v*) + detect_platform + praxis_platform_check + require_praxis_build "${want_ref}" + ;; + esac + fi # Rebuild the argument list without --ref: it is meta, consumed here, and a # released script from before --ref existed rejects it as an unknown option. # Rotating the positional parameters keeps arguments with spaces intact, @@ -1107,6 +1143,9 @@ hand_off() { [ -z "${NO_SERVICE}" ] || set -- "$@" --no-service [ "${MODE}" != "install-only" ] || set -- "$@" --install-only [ -z "${NO_MODIFY_PATH}" ] || set -- "$@" --no-modify-path + # Not frozen like the others: require_praxis_build refuses every release without a + # gateway archive, and the first one with it has an agentop that takes --praxis. + [ -z "${PRAXIS}" ] || set -- "$@" --praxis [ -z "${_ho_stage}" ] || set -- "$@" "--handoff-bytes=${bytes}" "--handoff-seconds=${secs}" # CORTEX_INSTALLER_DREW=1 tells setup that the title and the bar are on screen, so it # draws neither its header nor its "✓ downloaded" row. An environment variable, not @@ -1146,6 +1185,11 @@ if [ -n "${CPEX}" ]; then [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" != "1" ] \ || die "--cpex selects what to download, and AUTHBRIDGE_SKIP_DOWNLOAD=1 downloads nothing" fi +if [ -n "${PRAXIS}" ]; then + praxis_platform_check + [ "${AUTHBRIDGE_SKIP_DOWNLOAD:-}" != "1" ] \ + || die "--praxis selects what to download, and AUTHBRIDGE_SKIP_DOWNLOAD=1 downloads nothing" +fi # --- repair, offline: AUTHBRIDGE_SKIP_DOWNLOAD=1 hands the installed agentop to setup --- # Nothing is downloaded, so nothing is staged: setup without --from installs no @@ -1165,6 +1209,7 @@ version=$(resolve_version "${VERSION_REF}") \ || die "could not resolve the newest release (pass --ref=vX.Y.Z to pin one)" # The release may predate --cpex: say so before staging anything. [ -z "${CPEX}" ] || require_cpex_build "${version}" +[ -z "${PRAXIS}" ] || require_praxis_build "${version}" # --- download + verify --- # An explicit template under TMPDIR, not a bare `mktemp -d`: macOS's mktemp puts that @@ -1190,6 +1235,8 @@ base="https://github.com/${REPO}/releases/download/${version}" agentop_tgz="agentop_${version}_${os}_${arch}.tar.gz" proxy_tgz="cortex_${version}_${os}_${arch}.tar.gz" [ -z "${CPEX}" ] || proxy_tgz="cortex-cpex_${version}_${os}_${arch}.tar.gz" +gateway_tgz="" +[ -z "${PRAXIS}" ] || gateway_tgz="cortex-gateway_${version}_${os}_${arch}.tar.gz" # Leaving the cpex build should be visible: re-running the plain one-liner over it # installs the default build. The installed binary says which one it is ("cortex @@ -1310,6 +1357,11 @@ if [ -n "${bar_on}" ]; then bar_total=$(($(file_bytes "${tmp}/${agentop_tgz}") + $(file_bytes "${tmp}/${proxy_tgz}"))) bar_draw "${bar_total}" fi +# shortcut: the bar does not count the gateway archive, add it to the bar if users report a stall +if [ -n "${gateway_tgz}" ]; then + curl -fsSL "${base}/${gateway_tgz}" -o "${tmp}/${gateway_tgz}" 2>&4 \ + || { curl_errors; die "download failed: ${gateway_tgz}"; } +fi curl -fsSL "${base}/checksums.txt" -o "${tmp}/checksums.txt" 2>&4 \ || { curl_errors; die "download failed: checksums.txt"; } exec 4>&- @@ -1324,7 +1376,8 @@ rm -f "${tmp}/curl.err" # Matching is anchored to end-of-line so an unrelated future artifact in # checksums.txt can't make verification fail on a file we never fetched. : > "${tmp}/checksums.filtered" -for archive in "${agentop_tgz}" "${proxy_tgz}"; do +# shellcheck disable=SC2086 # gateway_tgz is empty or one word, split on purpose +for archive in "${agentop_tgz}" "${proxy_tgz}" ${gateway_tgz}; do # The name may be preceded by whitespace, sha256sum's binary-mode "*", or a # path component: the release workflow runs `sha256sum ./*.tar.gz`, so every # real line reads "HASH ./agentop_....tar.gz". An earlier version of this @@ -1344,10 +1397,12 @@ for archive in "${agentop_tgz}" "${proxy_tgz}"; do >> "${tmp}/checksums.filtered" \ || die "checksums.txt has no usable entry for ${archive} — refusing to install it unverified" done -# Both entries present, and exactly the two we asked for. +# Every entry present, and exactly the ones we asked for: 2, or 3 with the gateway. +want_lines=2 +[ -z "${gateway_tgz}" ] || want_lines=3 lines=$(wc -l < "${tmp}/checksums.filtered" | tr -d '[:space:]') -[ "${lines}" = "2" ] \ - || die "expected 2 checksum entries, got ${lines} — refusing to install" +[ "${lines}" = "${want_lines}" ] \ + || die "expected ${want_lines} checksum entries, got ${lines} — refusing to install" # Quiet on success, loud on failure. The per-archive "OK" lines are two lines saying # what one line implies — but sending them to /dev/null took the FAILED lines (stdout) # and shasum's "computed checksum did NOT match" warning (stderr) with them, so a @@ -1366,7 +1421,7 @@ fi # --- the download's counts, for setup's "✓ downloaded" line --- # At least 1 byte, and whole seconds: see hand_off for why each matters to setup. A # clock reading that is not a number counts as 0 seconds rather than failing here. -bytes=$(cat "${tmp}/${agentop_tgz}" "${tmp}/${proxy_tgz}" | wc -c | tr -d '[:space:]') +bytes=$(cat "${tmp}/${agentop_tgz}" "${tmp}/${proxy_tgz}" ${gateway_tgz:+"${tmp}/${gateway_tgz}"} | wc -c | tr -d '[:space:]') [ "${bytes:-0}" -gt 0 ] 2>/dev/null || bytes=1 finished=$(date +%s 2>/dev/null) || finished="" case "${started}:${finished}" in @@ -1380,14 +1435,19 @@ bar_verified "${bytes}" "${secs}" # --- extract into the stage --- tar -xzf "${tmp}/${agentop_tgz}" -C "$tmp" tar -xzf "${tmp}/${proxy_tgz}" -C "$tmp" -for b in agentop cortex; do +staged="agentop cortex" +if [ -n "${gateway_tgz}" ]; then + tar -xzf "${tmp}/${gateway_tgz}" -C "$tmp" + staged="${staged} cortex-gateway" +fi +for b in ${staged}; do [ -f "${tmp}/${b}" ] || die "archive did not contain expected binary: ${b}" chmod +x "${tmp}/${b}" done # macOS: clear the quarantine flag so Gatekeeper doesn't block the unsigned binaries. if [ "$os" = "darwin" ] && command -v xattr >/dev/null 2>&1; then - xattr -dr com.apple.quarantine "${tmp}/agentop" "${tmp}/cortex" 2>/dev/null || true + xattr -dr com.apple.quarantine "${tmp}/agentop" "${tmp}/cortex" ${gateway_tgz:+"${tmp}/cortex-gateway"} 2>/dev/null || true fi stage_session_dump diff --git a/scripts/install_test.sh b/scripts/install_test.sh index 9c2114b40..1f8f71358 100644 --- a/scripts/install_test.sh +++ b/scripts/install_test.sh @@ -1085,7 +1085,8 @@ if [ -t 0 ]; then echo 'stdin tty' >>"${T_LOG}"; else printf 'stdin [%s]\n' "$(c EOF printf '#!/bin/sh\necho "cortex v9.9.9"\n' >"${TMP}/fake-cortex" printf '#!/bin/sh\necho "cortex (cpex) v9.9.9"\n' >"${TMP}/fake-cortex-cpex" -chmod +x "${TMP}/fake-agentop" "${TMP}/fake-cortex" "${TMP}/fake-cortex-cpex" +printf '#!/bin/sh\necho "cortex-gateway v9.9.9"\n' >"${TMP}/fake-cortex-gateway" +chmod +x "${TMP}/fake-agentop" "${TMP}/fake-cortex" "${TMP}/fake-cortex-cpex" "${TMP}/fake-cortex-gateway" # uname, for the runs that set T_UNAME (" ", as uname -s and -m print # them), so a platform this machine is not can be tested. mkdir -p "${TMP}/unamebin" @@ -1236,6 +1237,9 @@ t_sha256() { # file # bad agentop's line carries the wrong hash # cpex a cortex-cpex archive too, listed beside the cortex one, as a release has it # nocpex the cortex-cpex archive, but no line for it +# praxis a cortex-gateway archive too, with its line +# nopraxis the cortex-gateway archive, but no line for it +# badpraxis the cortex-gateway archive, with a wrong hash on its line # The platform is T_OS/T_ARCH, or MR_OS/MR_ARCH when set. make_release() { T_REL="${TMP}/rel-$1" @@ -1244,10 +1248,12 @@ make_release() { cp "${TMP}/fake-agentop" "${T_REL}/src/agentop" cp "${TMP}/fake-cortex" "${T_REL}/src/cortex" cp "${TMP}/fake-cortex-cpex" "${T_REL}/src-cpex/cortex" + cp "${TMP}/fake-cortex-gateway" "${T_REL}/src/cortex-gateway" _mr_p="${MR_OS:-${T_OS}}_${MR_ARCH:-${T_ARCH}}" _mr_a="agentop_$1_${_mr_p}.tar.gz" _mr_c="cortex_$1_${_mr_p}.tar.gz" _mr_x="cortex-cpex_$1_${_mr_p}.tar.gz" + _mr_g="cortex-gateway_$1_${_mr_p}.tar.gz" # COPYFILE_DISABLE: macOS tar would otherwise add AppleDouble ._ entries. COPYFILE_DISABLE=1 tar -C "${T_REL}/src" -czf "${T_REL}/${_mr_a}" agentop COPYFILE_DISABLE=1 tar -C "${T_REL}/src" -czf "${T_REL}/${_mr_c}" cortex @@ -1258,6 +1264,11 @@ make_release() { COPYFILE_DISABLE=1 tar -C "${T_REL}/src-cpex" -czf "${T_REL}/${_mr_x}" cortex _mr_hx=$(t_sha256 "${T_REL}/${_mr_x}") ;; + praxis | nopraxis | badpraxis) + COPYFILE_DISABLE=1 tar -C "${T_REL}/src" -czf "${T_REL}/${_mr_g}" cortex-gateway + _mr_hg=$(t_sha256 "${T_REL}/${_mr_g}") + [ "$2" != badpraxis ] || _mr_hg=$(printf '%s' "${_mr_hg}" | tr '0-9a-f' '1-9a-f0') + ;; esac # Every hex digit moved by one, so the bad hash differs in every position. [ "${2:-two}" != bad ] || _mr_ha=$(printf '%s' "${_mr_ha}" | tr '0-9a-f' '1-9a-f0') @@ -1266,6 +1277,7 @@ make_release() { [ "${2:-two}" = one ] || printf '%s ./%s\n' "${_mr_hc}" "${_mr_c}" [ "${2:-two}" != three ] || printf '%s %s\n' "${_mr_ha}" "${_mr_a}" [ "${2:-two}" != cpex ] || printf '%s ./%s\n' "${_mr_hx}" "${_mr_x}" + case "${2:-two}" in praxis | badpraxis) printf '%s ./%s\n' "${_mr_hg}" "${_mr_g}" ;; esac } >"${T_REL}/checksums.txt" printf '#!/usr/bin/env python3\nprint("dump")\n' >"${T_REL}/cortex-session-dump.py" T_BYTES=$(($(wc -c <"${T_REL}/${_mr_a}") + $(wc -c <"${T_REL}/${_mr_c}"))) @@ -2083,7 +2095,65 @@ run_install ok release --ref=v9.9.9 --cpex --yes check "piped: the released copy gets --cpex, and not --ref" "child [--cpex] [--yes]" \ "$(grep '^child' "${T_RUN}/out")" +# --- --praxis: the cortex-gateway archive too, only where it is published --- +make_release v9.9.9 +T_UNAME="Darwin x86_64" +run_install ok release --ref=v9.9.9 --praxis +check "--praxis on darwin/amd64: dies naming the supported platforms" "1 yes" \ + "${T_ST} $(t_in 'error: --praxis is not available for darwin/amd64 (supported: darwin/arm64, linux/amd64, linux/arm64)' err)" +check " fetching nothing and running no agentop" " 0" "$(cat "${T_RUN}/urls") $(t_nlog argv)" + +MR_OS=linux MR_ARCH=amd64 make_release v9.9.9 nopraxis +T_UNAME="Linux x86_64" +run_install ok release --ref=v9.9.9 --praxis +check "--praxis with no cortex-gateway entry in checksums.txt dies" "1 yes" \ + "${T_ST} $(t_in 'error: release v9.9.9 has no cortex-gateway build for linux/amd64' err)" +check " before any archive is fetched" "0 0" "$(grep -c '\.tar\.gz$' "${T_RUN}/urls" || true) $(t_nlog argv)" + +MR_OS=linux MR_ARCH=amd64 make_release v9.9.9 praxis +T_UNAME="Linux x86_64" +run_install ok release --ref=v9.9.9 --praxis +check "--praxis on linux/amd64: hands off with --praxis" \ + "0 [setup] [--from] [] [--praxis] [--handoff-bytes=] [--handoff-seconds=]" "${T_ST} $(t_argv)" +check " staging an executable cortex-gateway" "yes cortex-gateway v9.9.9" \ + "$([ -x "$(t_stage)/cortex-gateway" ] && echo yes) $("$(t_stage)/cortex-gateway")" +check " verifying the gateway archive's entry too" \ + "./agentop_v9.9.9_linux_amd64.tar.gz ./cortex_v9.9.9_linux_amd64.tar.gz ./cortex-gateway_v9.9.9_linux_amd64.tar.gz" \ + "$(awk '{print $2}' "$(t_stage)/checksums.filtered" | tr '\n' ' ' | sed 's/ $//')" +T_UNAME="Linux x86_64" +run_install ok release --ref=v9.9.9 +check "without --praxis: no --praxis to setup, and no gateway fetched or staged" \ + "0 ${T_NOFLAGS} 0 no" \ + "${T_ST} $(t_argv) $(grep -c 'cortex-gateway' "${T_RUN}/urls" || true) $([ -e "$(t_stage)/cortex-gateway" ] && echo yes || echo no)" + +MR_OS=linux MR_ARCH=amd64 make_release v9.9.9 badpraxis +T_UNAME="Linux x86_64" +run_install ok release --ref=v9.9.9 --praxis +check "--praxis with a wrong gateway checksum dies" "1 yes 0" \ + "${T_ST} $(t_in 'checksum verification failed' err) $(t_nlog argv)" + +T_UNAME="Linux x86_64" +run_install ok repair --praxis +check "--praxis with AUTHBRIDGE_SKIP_DOWNLOAD=1 dies" "1 yes" \ + "${T_ST} $(t_in 'error: --praxis selects what to download, and AUTHBRIDGE_SKIP_DOWNLOAD=1 downloads nothing' err)" + +# Piped, a release without the gateway: refused before re-execing its install.sh. +MR_OS=linux MR_ARCH=amd64 make_release v9.9.9 +cat >"${T_REL}/install.sh" <<'EOS' +printf 'child'; for _a in "$@"; do printf ' [%s]' "${_a}"; done; printf '\n' +EOS +T_UNAME="Linux x86_64" +T_PIPE=1 +run_install ok release --ref=v9.9.9 --praxis +check "piped --praxis against a release with no gateway build: dies before re-exec" "1 yes 0" \ + "${T_ST} $(t_in 'error: release v9.9.9 has no cortex-gateway build for linux/amd64' err) $(grep -c '^child' "${T_RUN}/out" || true)" + +run_install ok release --help +check "--help lists --praxis" "1" "$(grep -c '^ --praxis ' "${T_RUN}/out" || true)" + run_install ok release --bogus +check "an unknown option's message lists --praxis" "1 yes" \ + "${T_ST} $(t_in '--no-service, --praxis, --stop' err)" check "an unknown option's message lists --cpex" "1 yes" \ "${T_ST} $(t_in 'unknown option: --bogus (try --claude-code, --cpex,' err)" run_install ok release --help